diff --git a/.claude/skills/releasing/SKILL.md b/.claude/skills/releasing/SKILL.md deleted file mode 100644 index 3f55e325..00000000 --- a/.claude/skills/releasing/SKILL.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -name: releasing -description: Cut and ship a new release of the `mergify` CLI — build the draft release with its platform binaries, publish it, and verify PyPI and the Homebrew tap picked it up. Use when asked to release, cut a release, ship a new version, publish a release, tag a version, or when a release run failed and needs recovery. Triggers on "release", "new version", "cut a release", "ship it", "publish the release", "bump the version", "release failed", "PyPI publish failed". ---- - -# Releasing `mergify-cli` - -Everything is driven by `.github/workflows/release.yml`. **There is no version -to bump in any file** — `pyproject.toml` and `Cargo.toml` keep their placeholder -versions and the workflow stamps the tag in at build time. Never open a "release -prep" PR. - -Versions are calver: `YYYY.M.N` (no zero padding, `N` counts releases within the -UTC month from 1, e.g. `2026.10.1`). Three components so the version is valid -SemVer for npm, since the CLI shares its version with the test-framework -clients. Releases before October 2026 used `YYYY.M.D.N`. - -`RELEASING.md` at the repo root is the human-facing runbook and explains *why* -the flow is shaped this way (GitHub's immutable-releases policy). Read it when -something goes wrong or the workflow itself needs changing. - -## Guardrails - -- **Never run `gh release create` or push a tag by hand.** A release created - outside the workflow has no binaries, and once published it is immutable — the - asset assertion then permanently blocks the PyPI publish for that version. -- **Never click / script "Draft a new release" in the Releases UI.** Same trap. -- **Stage 2 (Publish) is irreversible and outward-facing** — it locks the release - and pushes to PyPI. Always get the user's explicit go-ahead before publishing, - even if they already asked for "a release"; report the draft URL and stop. -- Stage 1 is safe and repeatable: a draft can be deleted and rebuilt. - -## Stage 1 — build the draft - -Pre-flight (report anything red, don't silently proceed): - -```shell -gh run list --workflow=ci.yaml --branch main --limit 1 # main is green -gh release list --limit 3 # no leftover draft -git log --oneline $(git describe --tags --abbrev=0)..origin/main # what ships -``` - -Trigger it: - -```shell -gh workflow run release.yml # auto-picks YYYY.M. -# or, only when a specific version is needed: -gh workflow run release.yml -f tag=2026.10.1 -f target_commitish= -``` - -Leave `tag` empty unless the user asked for a specific version; leave -`target_commitish` empty unless cherry-picking a release off an older line. - -Watch it (~5–10 min): - -```shell -gh run list --workflow=release.yml --limit 1 -gh run watch --exit-status -``` - -It builds the seven-target wheel matrix, extracts the `mergify` binary from each -wheel except the two musllinux ones (those only go to PyPI), packages the archives + `SHA256SUMS`, dumps `cli-schema.json`, signs the -binaries with build provenance, and creates the **draft** release with notes -generated from the PRs merged since the previous tag. - -Then verify — the draft must carry exactly these seven assets: - -```shell -tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName') -gh release view "$tag" --json isDraft,url,assets --jq '{draft:.isDraft,url:.url,assets:[.assets[].name]}' -``` - -- `mergify--x86_64-unknown-linux-gnu.tar.gz` -- `mergify--aarch64-unknown-linux-gnu.tar.gz` -- `mergify--x86_64-apple-darwin.tar.gz` -- `mergify--aarch64-apple-darwin.tar.gz` -- `mergify--x86_64-pc-windows-msvc.zip` -- `SHA256SUMS` -- `cli-schema.json` - -Give the user the draft URL and the generated notes to review. **Stop here.** - -## Stage 2 — publish - -Only after the user explicitly says to publish: - -```shell -gh release edit "$tag" --draft=false --latest -``` - -That fires `release: published`, which re-asserts the seven assets, rebuilds the -wheels with the same version stamp, and pushes to PyPI via Trusted Publishing -(~5–10 min). Watch the `release` run the same way as in stage 1. - -Note edits must happen *before* this (`gh release edit "$tag" --notes-file -notes.md`) — drafts are mutable, published releases are not. - -## Verify after publishing - -```shell -gh run list --workflow=release.yml --limit 2 # publish job green -curl -s https://pypi.org/pypi/mergify-cli/json | jq -r .info.version -gh pr list --repo Mergifyio/homebrew-tap --author mergify-ci-bot -``` - -The Homebrew formula bump is automatic: the `homebrew-tap-sync` workflow in -`Mergifyio/mergify-ci-bot` opens a PR against `Mergifyio/homebrew-tap` within -~20 minutes of publish, updating `RELEASE` and the four per-arch checksums from -`SHA256SUMS`. It still needs a human to merge it. The docs site picks up -`cli-schema.json` off the latest release on its own — nothing to do there. - -## Recovery - -| Symptom | Fix | -|---|---| -| Stage 1 failed mid-run | No release exists yet, or a draft does. Delete the draft (`gh release delete --cleanup-tag`) and re-run stage 1. | -| Draft missing assets | Delete the draft and re-run stage 1 — never backfill by hand. | -| `assert-binaries-present` failed after publish | The release was created outside the workflow and is now immutable. Delete the release *and* its tag, then re-run stage 1 with the same tag. | -| PyPI publish failed (transient / outage) | Wheels are built; re-run just the failed job: `gh run rerun --failed`. | -| Wrong version already on PyPI | PyPI versions can't be reused or overwritten. Ship the next `N` — do not try to reuse the tag. | diff --git a/.github/release.yml b/.github/release.yml deleted file mode 100644 index abbedc49..00000000 --- a/.github/release.yml +++ /dev/null @@ -1,6 +0,0 @@ -changelog: - exclude: - authors: - - dependabot[bot] - - mergify[bot] - - renovate[bot] diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml deleted file mode 100644 index 4246bb2e..00000000 --- a/.github/workflows/build-wheels.yml +++ /dev/null @@ -1,190 +0,0 @@ -name: build wheels - -# Reusable workflow that builds the per-target maturin wheel matrix. -# No source distribution: maturin would vendor the whole workspace -# into it, which publishes the full CLI source to PyPI. Called from: -# -# - `ci.yaml` on every PR — smoke build, no publish, version stays -# at the placeholder so PRs can iterate without colliding with -# PyPI versioning. -# - `release.yml` on `release: published` — same matrix, but the -# wheel version is stamped from `$GITHUB_REF` and the artifacts -# are uploaded for the publish job to push to PyPI. -# -# Targets: Linux x64 + arm64 (manylinux and musllinux), macOS x64 + -# arm64, Windows x64. Add new targets here when a new platform is on -# the support matrix — both CI and release pick it up automatically. -# -# Linux arm64 (glibc and musl) uses a native `ubuntu-26.04-arm` -# runner instead of cross-compiling: `ring`'s ARM-asm build script -# needs the C compiler to define `__ARM_ARCH`, which the cross-gcc -# inside the manylinux Docker image doesn't, and a native runner -# sidesteps the problem entirely. -# -# The musllinux wheels are for PyPI only: `release.yml` does not turn -# them into GitHub Release binaries, since `install.sh` and -# `self-update` only resolve the glibc targets. - -on: - workflow_call: - inputs: - stamp-version: - description: | - When true, replace `version = "..."` in pyproject.toml - before maturin runs. Source is `inputs.override-version` - when set (the workflow_dispatch draft path doesn't have a - tag yet), otherwise `${GITHUB_REF#refs/tags/}`. Keep false - for PR smoke builds so CI exercises the full path with - the placeholder version. - required: false - default: false - type: boolean - override-version: - description: | - Explicit version string to stamp into the wheel. Lets the - dispatch-driven draft job pass its `tag` input through — - no git tag exists yet at that point, so the build can't - derive it from `$GITHUB_REF`. Ignored when `stamp-version` - is false; required when both `stamp-version` is true and - the caller wasn't triggered by a `release` event. - required: false - default: '' - type: string - -jobs: - build-wheels: - name: build wheel (${{ matrix.target }}) - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-26.04 - target: x86_64-unknown-linux-gnu - manylinux: auto - - os: ubuntu-26.04-arm - target: aarch64-unknown-linux-gnu - manylinux: auto - # Alpine and other musl distros: pip can't install a - # manylinux (glibc) wheel there, and with no sdist - # published, a platform without a wheel can't install - # mergify-cli at all. - - os: ubuntu-26.04 - target: x86_64-unknown-linux-musl - manylinux: musllinux_1_2 - - os: ubuntu-26.04-arm - target: aarch64-unknown-linux-musl - manylinux: musllinux_1_2 - - os: macos-15 - target: x86_64-apple-darwin - manylinux: auto - - os: macos-15 - target: aarch64-apple-darwin - manylinux: auto - - os: windows-2025 - target: x86_64-pc-windows-msvc - manylinux: auto - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - fetch-tags: true - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: 3.14 - - - name: Stamp wheel version - if: inputs.stamp-version - env: - # Read the input through the environment, not `${{ }}` shell - # interpolation, so a value with shell metacharacters reaches - # bash as a plain string and can't break out of the script. - OVERRIDE_VERSION: ${{ inputs.override-version }} - shell: bash - run: | - set -eu - # `override-version` wins; falls back to the git tag the - # `release: published` flow rides on. The dispatch path - # passes `inputs.tag` here because no tag exists yet — it - # gets created by `gh release create` later in the - # workflow. - if [ -n "${OVERRIDE_VERSION}" ]; then - VERSION="${OVERRIDE_VERSION}" - else - VERSION="${GITHUB_REF#refs/tags/}" - fi - if [ -z "${VERSION}" ] || [ "${VERSION}" = "${GITHUB_REF}" ]; then - echo "::error::stamp-version is true but no version source is available (no tag in GITHUB_REF, no override-version input)" - exit 1 - fi - # `sed -i.bak`/rm form works on both BSD (macOS) and GNU - # (Linux) sed without divergent flag handling. On Windows - # the runner ships GNU sed via Git Bash, which honours the - # same syntax. - sed -i.bak -E "s/^version = \".*\"$/version = \"${VERSION}\"/" pyproject.toml - rm -f pyproject.toml.bak - echo "Stamped pyproject.toml:" - grep '^version = ' pyproject.toml - # Propagate the tag into the maturin build so `mergify - # --version` reports it. `Cargo.toml` stays at `0.0.0` - # (the tag is the version's single source of truth), and - # `crates/mergify-cli/build.rs` re-exports this env var - # as a `rustc-env` for the binary's `VERSION` const. - echo "MERGIFY_RELEASE_VERSION=${VERSION}" >> "${GITHUB_ENV}" - - - name: Build wheel - uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1 - with: - target: ${{ matrix.target }} - # `manylinux: auto` picks the most-compatible glibc the - # target supports, `musllinux_1_2` builds the musl legs; it - # is ignored on macOS and Windows. `--strip` shaves debug - # symbols off the bundled binary. - args: --release --strip --out target/wheels - manylinux: ${{ matrix.manylinux }} - sccache: true - # Forward MERGIFY_RELEASE_VERSION into the manylinux Docker - # container. On Linux maturin-action runs the build inside a - # container and only propagates env vars whose names match an - # allowlist of prefixes (CARGO_*, GITHUB_*, MATURIN_*, etc.). - # MERGIFY_RELEASE_VERSION doesn't match any prefix, so without - # this the variable is silently dropped and build.rs falls back - # to CARGO_PKG_VERSION (0.0.0). macOS and Windows build natively - # on the runner and inherit the host env, so they were unaffected. - docker-options: -e MERGIFY_RELEASE_VERSION - - # `twine check --strict` validates the wheel's metadata against - # the same rules PyPI's upload validator applies (README - # rendering, License-File presence, Description-Content-Type, - # etc.). Running it on every PR catches packaging-metadata - # regressions at PR time instead of at the next `release: - # published` event. - - name: Validate wheel metadata - shell: bash - run: | - python -m pip install --quiet twine - twine check --strict target/wheels/*.whl - - # The musl legs are the only ones built on a libc their runner - # doesn't have, so `ci.yaml`'s smoke-test-binary can't reach - # them. Install the wheel with pip in an Alpine container and - # run it, which also proves pip accepts the musllinux tag. - - name: Smoke-test the wheel on Alpine - if: matrix.manylinux == 'musllinux_1_2' - shell: bash - run: | - docker run --rm -v "${PWD}/target/wheels:/wheels:ro" python:3.14-alpine \ - sh -euc 'pip install --quiet --no-index --find-links /wheels mergify-cli && mergify --version' - - # Only upload artifacts when the caller actually needs them - # (release.yml downloads them in its publish job). PR smoke - # builds just need the build to succeed — keeping the - # artifact upload off saves cache space and a few seconds per - # platform. - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: inputs.stamp-version - with: - name: wheel-${{ matrix.target }} - path: target/wheels/*.whl diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e2bd086..2463ddd5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,74 +15,6 @@ concurrency: cancel-in-progress: true jobs: - rust: - timeout-minutes: 10 - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Install Rust toolchain - # No version argument: installs the channel + components - # pinned in rust-toolchain.toml. - run: rustup toolchain install - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - - name: cargo fmt - run: cargo fmt --all --check - - - name: cargo clippy - run: cargo clippy --all-targets --all-features --workspace --locked -- -D warnings - - - name: cargo test - run: cargo test --workspace --all-features --locked - - - name: cargo build --release - run: cargo build --release --locked - - # Verify the workspace compiles on the declared MSRV. The version - # is read from Cargo.toml so the floor stays a single source of - # truth — bump rust-version there and this job follows it. `--locked` - # makes the committed lockfile part of the contract. - msrv: - name: cargo check (MSRV) - timeout-minutes: 15 - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Resolve MSRV from Cargo.toml - id: msrv - run: | - set -euo pipefail - msrv=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*"([0-9.]+)".*/\1/') - echo "Declared MSRV: ${msrv}" - echo "version=${msrv}" >> "${GITHUB_OUTPUT}" - - name: Install MSRV toolchain - run: rustup toolchain install "${{ steps.msrv.outputs.version }}" --profile minimal - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: msrv - - name: cargo check --locked on MSRV - # RUSTUP_TOOLCHAIN outranks rust-toolchain.toml (which - # outranks `rustup default` — so without this env the job - # silently checks the pinned toolchain, not the MSRV). - run: cargo check --workspace --all-targets --locked - env: - RUSTUP_TOOLCHAIN: ${{ steps.msrv.outputs.version }} - - # Supply-chain gate: RUSTSEC advisories, license policy, banned / - # duplicate crates, and source provenance. Policy lives in - # deny.toml; reproduce locally with `cargo deny check`. - cargo-deny: - name: cargo-deny - timeout-minutes: 10 - runs-on: ubuntu-26.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2 - with: - command: check advisories bans licenses sources - # Spellcheck source, comments, and docs. Allow-list of project # nouns lives in _typos.toml; reproduce locally with `typos`. typos: @@ -93,350 +25,6 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 - # Smoke build the full release wheel matrix on every PR so a - # cross-compile or platform-specific maturin failure is caught - # here, not on the next `release: published` event. Calls the - # same reusable workflow the release uses, with version stamping - # disabled (the placeholder "0.0.0" stays in pyproject.toml so - # the wheel name doesn't pretend to be a release version). - wheels: - uses: ./.github/workflows/build-wheels.yml - with: - stamp-version: false - - # Confirm the binary actually runs out of the freshly-built wheel - # on each supported runtime. Lightweight smoke — `mergify --help` - # exits 0 — covers the most common breakage class (a maturin - # config change that ships a wheel with a broken or missing - # binary entry, or a libc / runtime-link issue that only shows - # up post-install). Builds the wheel locally with maturin - # rather than downloading the `wheels`-job artifacts, since - # `wheels` only uploads in release mode (PR smoke runs are - # `stamp-version: false`). - smoke-test-binary: - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - os: [ubuntu-26.04, windows-2025, macos-15] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: 3.14 - - name: Install Rust toolchain - # No version argument: installs the channel pinned in - # rust-toolchain.toml. - run: rustup toolchain install - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: smoke-${{ matrix.os }} - - name: Build wheel - shell: bash - run: | - pip install 'maturin~=1.7' - maturin build --release --out dist - - name: Install wheel - shell: bash - run: pip install dist/*.whl - - name: mergify --help - shell: bash - run: mergify --help - - # Lock the dev-build version fallback in. Release builds get - # `MERGIFY_RELEASE_VERSION` from the stamp step in - # `build-wheels.yml`; PR smoke builds don't, so the binary - # must report `Cargo.toml`'s `0.0.0` placeholder. If this - # ever changes silently, every release-version assumption - # downstream (PyPI install reporting, support diagnostics) - # rests on the same code path — catch the drift here. - - name: mergify --version reports placeholder on unstamped build - shell: bash - run: | - out=$(mergify --version) - echo "$out" - [[ "$out" == "mergify 0.0.0" ]] || { - echo "expected 'mergify 0.0.0', got '$out'" - exit 1 - } - - # End-to-end exercise of the `install.sh` one-liner: build the - # release binary, pretend to be a GitHub Release by serving the - # repackaged `mergify-.tar.gz` + `SHA256SUMS` through a - # local HTTP fixture, then run `install.sh` against it and assert - # the installed binary works. This catches the install path that - # the unit-style shellcheck pass can't — detect_target mapping, - # sha256 verification, the `install -m 0755` step, and the final - # `--version` exec. - # - # Windows runs the same POSIX `install.sh` under git-bash - # (`shell: bash`), pulling the `.zip` asset and `mergify.exe` - # instead of the `.tar.gz` + `mergify` the Unix legs use. - install-script-smoke: - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - os: [ubuntu-26.04, macos-15, windows-2025] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install Rust toolchain - # No version argument: installs the channel pinned in - # rust-toolchain.toml. - run: rustup toolchain install - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: install-${{ matrix.os }} - - - name: Install shellcheck (Linux only) - if: matrix.os == 'ubuntu-26.04' - run: sudo apt-get install -y --no-install-recommends shellcheck - - name: Lint install.sh - if: matrix.os == 'ubuntu-26.04' - run: shellcheck install.sh - - # Build the binary the same way the release path does, then - # repackage it under the exact name/structure `install.sh` - # expects so the fixture is bit-identical to a real release - # asset. - - name: Build release binary - run: cargo build --release -p mergify-cli - - - name: Pack release fixture - shell: bash - run: | - set -euo pipefail - # `sha256sum` (Linux, git-bash) or `shasum -a 256` (macOS) — - # same fallback install.sh uses; git-bash ships no `shasum`. - sha256sums() { - if command -v sha256sum > /dev/null 2>&1; then sha256sum "$@" - else shasum -a 256 "$@"; fi - } - target=$(rustc -vV | awk '/^host:/ {print $2}') - echo "Building fixture for target: ${target}" - # Mirror what install.sh derives from the triple: Windows - # ships a `.zip` holding `mergify.exe`, everything else a - # `.tar.gz` holding `mergify`. - case "${target}" in - *windows*) ext="zip"; bin="mergify.exe" ;; - *) ext="tar.gz"; bin="mergify" ;; - esac - asset="mergify-2099.1.1-${target}.${ext}" - mkdir -p fixture/release - # install.sh resolves the version from latest-release.json (fixture - # mode) before building the per-version asset name. - echo '{"tag_name":"2099.1.1"}' > fixture/release/latest-release.json - if [ "${ext}" = "zip" ]; then - # Pack with python's zipfile rather than betting on a - # `zip` binary in git-bash; python is already required - # below for the fixture server. Resolve the interpreter - # name the same way the serve step does (`python3` on - # Unix, `python` on the Windows runner). - py=python3; command -v "$py" > /dev/null 2>&1 || py=python - ( cd target/release \ - && "$py" -m zipfile -c "${OLDPWD}/fixture/release/${asset}" "${bin}" ) - else - tar -C target/release -czf "fixture/release/${asset}" "${bin}" - fi - # Normalise to the canonical "HASH␣␣filename" form the - # release pipeline emits on Linux. git-bash's `sha256sum` - # tags binary files with a `*` marker (`HASH *file`), which - # would break install.sh's `$2 == asset` lookup and its - # two-space line-shape check. - ( cd fixture/release \ - && hash=$(sha256sums "${asset}" | awk '{print $1}') \ - && printf '%s %s\n' "${hash}" "${asset}" > SHA256SUMS ) - ls -la fixture/release - - - name: Serve fixture - shell: bash - run: | - cd fixture/release - # `python3` on the Unix runners, `python` on the Windows - # runner (it ships no `python3` shim). The http.server - # module is in the stdlib on all of them. Bind to localhost - # only; the runner is ephemeral but there's no point - # exposing the fixture. - py=python3; command -v "$py" > /dev/null 2>&1 || py=python - "$py" -m http.server 8765 --bind 127.0.0.1 > /tmp/server.log 2>&1 & - echo $! > /tmp/server.pid - # Wait for the server to actually accept connections. - for _ in $(seq 1 50); do - curl -fsS http://127.0.0.1:8765/SHA256SUMS > /dev/null && break - sleep 0.1 - done - - - name: Run install.sh against the fixture - shell: bash - run: | - set -euo pipefail - export MERGIFY_BASE_URL=http://127.0.0.1:8765 - export MERGIFY_INSTALL_DIR=/tmp/mergify-install - ./install.sh - # `install.sh` runs `--version` itself, but pin it here - # too so a future refactor that drops the post-install - # smoke from the script still trips this job. The binary - # is `mergify.exe` on Windows. - case "$(rustc -vV | awk '/^host:/ {print $2}')" in - *windows*) bin=mergify.exe ;; - *) bin=mergify ;; - esac - "/tmp/mergify-install/${bin}" --version - - # Negative paths: corrupted SHA256SUMS must fail closed, not - # silently install a tampered binary. Tests both the - # `sha256sum -c` mismatch path (right shape, wrong hash) and - # the malformed-line path (`sha256sum -c` only warns on - # malformed lines, so install.sh validates the line shape - # separately). - - name: install.sh rejects a tampered SHA256SUMS - shell: bash - run: | - set -euo pipefail - target=$(rustc -vV | awk '/^host:/ {print $2}') - case "${target}" in *windows*) ext=zip ;; *) ext=tar.gz ;; esac - asset="mergify-2099.1.1-${target}.${ext}" - # Keep a clean copy so the malformed test below can write - # its own bad version without losing the original. - cp fixture/release/SHA256SUMS fixture/release/SHA256SUMS.orig - - echo "Case 1: wrong-but-well-formed hash" - printf '%064d %s\n' 0 "${asset}" > fixture/release/SHA256SUMS - ! MERGIFY_BASE_URL=http://127.0.0.1:8765 \ - MERGIFY_INSTALL_DIR=/tmp/mergify-bad1 \ - ./install.sh > /tmp/case1.log 2>&1 - grep -q 'checksum verification failed' /tmp/case1.log - - echo "Case 2: malformed line" - echo "bogus ${asset}" > fixture/release/SHA256SUMS - ! MERGIFY_BASE_URL=http://127.0.0.1:8765 \ - MERGIFY_INSTALL_DIR=/tmp/mergify-bad2 \ - ./install.sh > /tmp/case2.log 2>&1 - grep -q 'malformed checksum entry' /tmp/case2.log - - # Restore — the next steps may depend on a working fixture. - mv fixture/release/SHA256SUMS.orig fixture/release/SHA256SUMS - - - name: Stop fixture server - if: always() - shell: bash - run: | - if [ -f /tmp/server.pid ]; then - kill "$(cat /tmp/server.pid)" || true - fi - - # Same fixture pattern as `install-script-smoke`, but exercises - # `mergify self-update`. Builds the release binary, packages it - # like a real release would, points the binary at a localhost - # fixture via `MERGIFY_BASE_URL`, and runs the full download + - # verify + atomic-swap path. Plus a negative case proving a - # tampered `SHA256SUMS` reject (binary must stay untouched). - self-update-smoke: - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - os: [ubuntu-26.04, macos-15] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install Rust toolchain - # No version argument: installs the channel pinned in - # rust-toolchain.toml. - run: rustup toolchain install - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: self-update-${{ matrix.os }} - - - name: Build release binary - run: cargo build --release -p mergify-cli - - # Fixture shape `self_update` expects in `MERGIFY_BASE_URL` - # mode: a flat dir holding `latest-release.json` (the API - # stub), the asset tarball, and `SHA256SUMS`. The stub mirrors - # GitHub's `releases/latest` payload — `assets[]` carries the - # `browser_download_url` the binary downloads from, so the - # smoke test exercises the same discovery path as production - # (no filename reconstruction). - - name: Pack self-update fixture - shell: bash - run: | - set -euo pipefail - target=$(rustc -vV | awk '/^host:/ {print $2}') - echo "Building fixture for target: ${target}" - mkdir -p fixture/su - asset="mergify-2099.1.1-${target}.tar.gz" - tar -C target/release -czf "fixture/su/${asset}" mergify - (cd fixture/su && shasum -a 256 "${asset}" > SHA256SUMS) - # Pretend a far-future release is available so the - # `current == latest` short-circuit doesn't fire. - base="http://127.0.0.1:8766" - cat > fixture/su/latest-release.json < /tmp/su-server.log 2>&1 & - echo $! > /tmp/su-server.pid - for _ in $(seq 1 50); do - curl -fsS http://127.0.0.1:8766/SHA256SUMS > /dev/null && break - sleep 0.1 - done - - - name: self-update --check reports both versions - shell: bash - run: | - set -euo pipefail - out=$(MERGIFY_BASE_URL=http://127.0.0.1:8766 target/release/mergify self-update --check) - echo "$out" - echo "$out" | grep -q 'Current: 0.0.0' - echo "$out" | grep -q 'Latest: 2099.1.1' - - - name: self-update happy path swaps the binary - shell: bash - run: | - set -euo pipefail - cp target/release/mergify /tmp/mergify-su - MERGIFY_BASE_URL=http://127.0.0.1:8766 /tmp/mergify-su self-update - # Sanity: the swapped binary still runs. - /tmp/mergify-su --version - - - name: self-update rejects tampered SHA256SUMS - shell: bash - run: | - set -euo pipefail - target=$(rustc -vV | awk '/^host:/ {print $2}') - cp target/release/mergify /tmp/mergify-su-tamper - before=$(shasum -a 256 /tmp/mergify-su-tamper | awk '{print $1}') - # Replace SHA256SUMS with a wrong-but-well-formed hash. - printf '%064d mergify-2099.1.1-%s.tar.gz\n' 0 "${target}" > fixture/su/SHA256SUMS - ! MERGIFY_BASE_URL=http://127.0.0.1:8766 /tmp/mergify-su-tamper self-update \ - > /tmp/su-tamper.log 2>&1 - grep -q 'checksum mismatch' /tmp/su-tamper.log - # The binary must NOT have been swapped. - after=$(shasum -a 256 /tmp/mergify-su-tamper | awk '{print $1}') - [ "$before" = "$after" ] || { echo "binary was tampered!"; exit 1; } - - - name: Stop fixture server - if: always() - shell: bash - run: | - if [ -f /tmp/su-server.pid ]; then - kill "$(cat /tmp/su-server.pid)" || true - fi - # The agent skills against the binary people actually get: the latest # release's. The CLI's source isn't in this repository any more, so there is # no build to check them against. See scripts/check-skills.py. @@ -548,19 +136,41 @@ jobs: run: | if [ -f /tmp/server.pid ]; then kill "$(cat /tmp/server.pid)" || true; fi + # The published files (see AGENTS.md) come from the CLI's private source, + # copied by ci-bot's public-releases-sync as of each release: an edit made + # here would be silently overwritten by the next sync. Only that sync's own + # pull requests may change them. + synced-files: + name: synced files only change through the sync + if: github.event.pull_request.user.login != 'mergify-ci-bot' + timeout-minutes: 5 + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + - name: Check no synced file changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + changed="$(git diff --name-only "${BASE_SHA}...HEAD" -- \ + README.md install.sh skills .claude-plugin .codex-plugin assets scripts/check-skills.py)" + if [ -n "${changed}" ]; then + for file in ${changed}; do + echo "::error file=${file}::${file} is published from the CLI's source and synced here at each release; edit it there (see AGENTS.md)" + done + exit 1 + fi + echo "no synced file changed" + ci-gate: if: ${{ !cancelled() }} needs: - - rust - - msrv - - cargo-deny - typos - - wheels - - smoke-test-binary - - install-script-smoke - - self-update-smoke - skills - install-script-release + - synced-files runs-on: ubuntu-26.04 steps: - name: Verify all jobs succeeded diff --git a/.github/workflows/func-tests-live.yaml b/.github/workflows/func-tests-live.yaml deleted file mode 100644 index ef05714b..00000000 --- a/.github/workflows/func-tests-live.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: Live functional tests - -# Hits the real Mergify API against -# mergify-clients-testing/mergify-cli-repo PR #1. Runs on every PR. -# NOT wired into the PR `ci-gate` job — an upstream blip must not -# block PRs. See `crates/mergify-cli/tests/live_smoke.rs`. - -permissions: read-all - -on: - pull_request: - -# Drop a superseded run on the same ref — these hit the live Mergify -# API, so finishing an obsolete run just wastes a real API budget. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - live-tests: - timeout-minutes: 10 - runs-on: ubuntu-26.04 - environment: func-tests-live - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Install Rust toolchain - run: | - rustup toolchain install stable --profile minimal - rustup default stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - - name: Live smoke tests - env: - # Two tokens with different scopes: - # - _CI exercises CI-tooling endpoints (scopes-send, - # junit-process). ci git-refs / queue-info are - # locally evaluated and need no token at all. - # - _ADMIN exercises every endpoint under /merge-queue/ - # (status, show, pause, unpause), the /scheduled_freeze - # endpoints (list, create, update, delete), and the - # CI-Insights test reads behind `tests show`. The CI - # token is rejected with 403 on all of these, so they - # share the admin token. - # Tests early-return with `SKIP:` when their token is - # unset rather than failing — local runs without secrets - # still produce a useful subset. - LIVE_TEST_MERGIFY_TOKEN_CI: ${{ secrets.MERGIFY_CLI_LIVE_TEST_MERGIFY_TOKEN_CI }} - LIVE_TEST_MERGIFY_TOKEN_ADMIN: ${{ secrets.MERGIFY_CLI_LIVE_TEST_MERGIFY_TOKEN_ADMIN }} - # `--nocapture` so the `SKIP:` lines stay visible — without - # it, cargo eats stderr on passing tests and a silently- - # skipped run on a missing-secret regression would look - # green. - run: cargo test --release --test live_smoke -- --nocapture diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7dc504ea..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,409 +0,0 @@ -name: release - -# Two-stage release flow, forced by GitHub's immutable-releases -# policy (assets can't be added after publish — PR #1569 dropped a -# prior `gh release upload` job for the same reason). Naïvely you'd -# expect `release: created` to fire for draft saves and let us -# attach binaries before publish, but GitHub Actions filters -# release events on drafts: nothing fires until the release becomes -# visible. So the prepare stage runs from `workflow_dispatch` -# instead — the maintainer drives it from the Actions UI. -# -# Stage 1 — prepare-draft (`workflow_dispatch`): -# Maintainer opens Actions → "release" → "Run workflow" → enters -# the tag (e.g. `2026.10.1`) or leaves it empty. The workflow builds the wheel -# matrix, extracts the binary from each wheel, packages -# `mergify--.{tar.gz,zip}` + `SHA256SUMS`, dumps -# the CLI schema to `cli-schema.json`, and creates the GitHub -# Release as a *draft* with all assets attached and notes -# auto-generated from the commit log via `--generate-notes`. -# -# Stage 2 — publish-stable (`release: published`): -# Maintainer goes to Releases, reviews the draft (notes + asset -# list), optionally edits, clicks "Publish release". This fires -# `release: published`. We assert the expected asset names are -# present (sanity net if the maintainer somehow stripped them -# before publish), then build wheels again and push to PyPI. -# The release is immutable from this point on. -# -# Yes, wheels are built twice (once per stage). Stashing them -# between two separately-triggered runs adds cross-run artifact -# plumbing for ~5 min of CI savings; not worth it. `build-wheels.yml` -# is shared with `ci.yaml` so adding a new platform target there -# extends both stages automatically. - -on: - workflow_dispatch: - inputs: - tag: - description: | - Release tag to publish, `YYYY.M.N` (e.g. `2026.10.1`). - Leave empty to auto-pick `YYYY.M.` where `` is - one past the highest version taken this UTC month (1 if - no release has shipped this month yet). - required: false - type: string - default: '' - target_commitish: - description: | - Branch or commit SHA to tag. Defaults to the branch the - workflow runs from — keep as-is unless you're cherry- - picking a release commit off an older line. - required: false - type: string - default: '' - release: - types: [published] - -# Every dispatch shares one group, whatever its tag: an auto run and -# an explicit one naming the version auto would pick (or two auto -# runs) could otherwise resolve the same `YYYY.M.` side by -# side and race to create the same draft. Releases are rare enough -# that queueing them costs nothing. `release: published` runs key -# off their tag — the version is fixed by then. Don't -# cancel-in-progress: a release run end-to-end is the only correct -# outcome — partial completion would leave a wheel mismatch with -# the PyPI publish. -concurrency: - group: release-${{ github.event_name == 'release' && github.event.release.tag_name || 'dispatch' }} - cancel-in-progress: false - -jobs: - # ---------------- Stage 1: prepare draft ---------------- - - compute-tag: - name: resolve release tag - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-26.04 - permissions: - # checkout + the tag list, and the release list: GitHub only - # lists draft releases to a token that can push, and a pending - # draft holds its version without any tag existing yet. - contents: write - outputs: - tag: ${{ steps.resolve.outputs.tag }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # `git tag -l` needs the full tag list to find this - # month's highest counter. - fetch-depth: 0 - fetch-tags: true - - # Either honour an explicit `tag` input or synthesise - # `YYYY.M.` from this UTC month and the versions already - # taken. Three components because npm only takes SemVer and - # the CLI releases under one version with the npm, PyPI and - # RubyGems clients. Both paths validate the final shape so a - # typo doesn't drift through the matrix, and refuse a version - # already taken before the builds rather than at - # `gh release create`. - - id: resolve - env: - INPUT_TAG: ${{ inputs.tag }} - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - shell: bash - run: | - set -euo pipefail - # A version is taken once a tag names it (the release was - # published) or a release does (including a draft still - # awaiting review, whose tag only appears on publish). - taken="$( { git tag -l; gh api --paginate "repos/${REPO}/releases" --jq '.[].tag_name'; } | sort -u)" - if [ -n "${INPUT_TAG}" ]; then - tag="${INPUT_TAG}" - echo "Using explicit tag: ${tag}" - else - # `%-m` drops the leading zero (`2026.9`, not - # `2026.09`). UTC so the rollover is the same - # regardless of which maintainer triggers the run. - month="$(date -u +'%Y.%-m')" - # `grep` exiting 1 on no match is the legitimate - # "nothing shipped this month yet" path — swallow only - # that, keeping pipefail otherwise. The anchored - # pattern keeps the old 4-component `YYYY.M.D.N` tags - # out of the count. - last_n="$(echo "${taken}" \ - | { grep -E "^${month//./\\.}\\.[0-9]+$" || true; } \ - | awk -F. '{print $NF}' \ - | sort -n \ - | tail -1)" - tag="${month}.$(( ${last_n:-0} + 1 ))" - echo "Auto-computed tag: ${tag}" - fi - # Final shape check — `gh release create` would accept - # any string, and npm would refuse a fourth component. - if ! [[ "${tag}" =~ ^[0-9]{4}\.(1[0-2]|[1-9])\.[1-9][0-9]*$ ]]; then - echo "::error::tag '${tag}' doesn't match the YYYY.M.N calver shape" - exit 1 - fi - if grep -qxF "${tag}" <<<"${taken}"; then - echo "::error::${tag} is already taken by a tag or a release (a pending draft included); publish or delete it first" - exit 1 - fi - echo "tag=${tag}" >> "${GITHUB_OUTPUT}" - - build-wheels-for-draft: - name: build wheels (draft stage) - if: github.event_name == 'workflow_dispatch' - needs: compute-tag - uses: ./.github/workflows/build-wheels.yml - with: - stamp-version: true - override-version: ${{ needs.compute-tag.outputs.tag }} - - create-draft-release: - name: create draft release with binaries - if: github.event_name == 'workflow_dispatch' - needs: [compute-tag, build-wheels-for-draft] - runs-on: ubuntu-26.04 - permissions: - # `gh release create` writes the release. - contents: write - # actions/attest-build-provenance mints a signed provenance for - # each binary asset (id-token to sign, attestations to record). - id-token: write - attestations: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - # `--generate-notes` needs full history to walk back to - # the previous tag. - fetch-depth: 0 - fetch-tags: true - - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: wheel-* - path: artifacts - - # Each `wheel-` artifact is a maturin-built wheel with - # the `mergify` binary at `.data/scripts/mergify[.exe]`. - # Extract it, normalise into - # `mergify--.{tar.gz,zip}`, then hash the lot - # with sha256 so the installer can verify what it pulled. - # Embedding the version (before the Rust triple) lets - # Homebrew's dotted-version parser win over the fallback stem - # regex that would otherwise mangle `x86_64` assets. - - name: Extract binaries and repack - env: - INPUT_TAG: ${{ needs.compute-tag.outputs.tag }} - shell: bash - run: | - set -euo pipefail - mkdir -p dist - for dir in artifacts/wheel-*; do - target="${dir#artifacts/wheel-}" - # The musllinux wheels exist for `pip install` on Alpine; - # the release binaries stay the glibc set that - # `install.sh`, `self-update` and the asset assertion - # below expect. - if [[ "${target}" == *-musl ]]; then - continue - fi - whl=$(ls "${dir}"/*.whl | head -1) - workdir=$(mktemp -d) - # Pick the binary name up-front from the target — every - # wheel ships exactly one of `mergify` or `mergify.exe`, - # never both. Passing *both* patterns to `unzip` made it - # exit 11 ("no matching files") for whichever pattern - # didn't apply, even when the other one did extract, - # killing the script under `set -e`. - if [[ "${target}" == *windows* ]]; then - bin=mergify.exe - else - bin=mergify - fi - # `-joq` is the combined form of `-j -o -q`: junk paths - # (flatten), overwrite without prompting, quiet. The - # wheel layout is `.data/scripts/`; we only - # want `` extracted at the workdir root. - unzip -joq "${whl}" "*/scripts/${bin}" -d "${workdir}" - if [[ "${target}" == *windows* ]]; then - (cd "${workdir}" && zip -q "${OLDPWD}/dist/mergify-${INPUT_TAG}-${target}.zip" "${bin}") - else - chmod +x "${workdir}/${bin}" - tar -C "${workdir}" -czf "dist/mergify-${INPUT_TAG}-${target}.tar.gz" "${bin}" - fi - rm -rf "${workdir}" - done - (cd dist && sha256sum mergify-* > SHA256SUMS) - echo "Built release assets:" - ls -la dist - - # Sign every binary asset with a build-provenance attestation, - # so a downloader can prove it was built by this workflow from - # this repo: `gh attestation verify --repo Mergifyio/mergify-cli`. - # SHA256SUMS already chains integrity to these subjects, so it - # needs no separate attestation. - - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 - with: - subject-path: dist/mergify-* - - # Dump the CLI schema straight off the released binary so the - # docs site renders the command reference from the same artifact - # it ships — it can't drift from the binary. The x86_64 Linux - # wheel's binary runs natively on the ubuntu-26.04 runner. Stable - # filename (the version is carried inside, under `cli.version`) - # so docs tooling can fetch it off the latest release without - # resolving the tag. Not added to SHA256SUMS — that file is the - # installer's binary-integrity manifest, not a docs artifact. - - name: Dump CLI schema - shell: bash - run: | - set -euo pipefail - whl=$(ls artifacts/wheel-x86_64-unknown-linux-gnu/*.whl | head -1) - workdir=$(mktemp -d) - unzip -joq "${whl}" "*/scripts/mergify" -d "${workdir}" - chmod +x "${workdir}/mergify" - "${workdir}/mergify" _internal dump-cli-schema > dist/cli-schema.json - rm -rf "${workdir}" - echo "Wrote dist/cli-schema.json ($(wc -c < dist/cli-schema.json) bytes)" - - - name: Create draft release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - # Read inputs via env, not `${{ }}` interpolation, so a - # tag or commitish value containing shell metacharacters - # can't break out of the script. With direct - # interpolation, `inputs.tag = "foo'; rm -rf /; '"` would - # render the script as runnable injected commands; - # through env they reach bash as plain string values. - INPUT_TAG: ${{ needs.compute-tag.outputs.tag }} - INPUT_TARGET: ${{ inputs.target_commitish }} - shell: bash - run: | - set -euo pipefail - # Build the gh argument list as an array — keeps each - # element a single argv slot regardless of the value's - # contents. - args=( - "${INPUT_TAG}" - --draft - --generate-notes - --title "${INPUT_TAG}" - ) - if [ -n "${INPUT_TARGET}" ]; then - args+=(--target "${INPUT_TARGET}") - fi - # `--draft` keeps the release mutable until the maintainer - # clicks Publish. `--generate-notes` builds a changelog - # from PRs merged since the previous tag — overridable in - # the UI before publish if needed. - gh release create "${args[@]}" dist/* - - url=$(gh release view "${INPUT_TAG}" --json url --jq .url) - echo - echo "Draft release created: ${url}" - echo "Review the draft and click Publish in the Releases UI to ship." - - # ---------------- Stage 2: publish + PyPI ---------------- - # - # Every stage-2 job skips releases authored by mergify-ci-bot. Those are - # the test clients' releases (pytest-mergify, rspec-mergify, @mergifyio/*), - # which their now-private repository publishes itself and ci-bot mirrors - # here so their notes and files stay public. They carry no CLI binaries and - # must not reach PyPI as mergify-cli; without the skip, every mirror would - # fail the asset check below. A release created with a user token starts - # workflows, unlike one created with GITHUB_TOKEN, hence the check on the - # author rather than on how it was created. - - assert-binaries-present: - name: assert binary assets attached - if: github.event_name == 'release' && github.event.action == 'published' && github.event.release.author.login != 'mergify-ci-bot' - runs-on: ubuntu-26.04 - permissions: - contents: read - steps: - - name: Check expected assets are on the release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - shell: bash - run: | - set -euo pipefail - tag="${{ github.event.release.tag_name }}" - mapfile -t attached < <(gh release view "$tag" --json assets --jq '.assets[].name') - echo "Assets on $tag:" - printf ' %s\n' "${attached[@]}" - - expected=( - "mergify-${tag}-x86_64-unknown-linux-gnu.tar.gz" - "mergify-${tag}-aarch64-unknown-linux-gnu.tar.gz" - "mergify-${tag}-x86_64-apple-darwin.tar.gz" - "mergify-${tag}-aarch64-apple-darwin.tar.gz" - "mergify-${tag}-x86_64-pc-windows-msvc.zip" - SHA256SUMS - cli-schema.json - ) - missing=() - for asset in "${expected[@]}"; do - printf '%s\n' "${attached[@]}" | grep -qxF "$asset" || missing+=("$asset") - done - if (( ${#missing[@]} > 0 )); then - cat <&2 - ::error::Release $tag is missing assets: ${missing[*]} - - Binary assets are attached by the 'create-draft-release' job, which - runs from Actions → "release" → "Run workflow". Did you create this - release through the Releases UI directly instead? That path doesn't - attach binaries, and the release is now immutable so they can't be - backfilled — the PyPI publish is being blocked so the package and the - binary distributions don't diverge. - - Recovery: delete this release, then re-create it via Actions → - "release" → "Run workflow" with the same tag. - EOF - exit 1 - fi - echo "All expected assets present — proceeding." - - build-wheels-for-publish: - name: build wheels (publish stage) - if: github.event_name == 'release' && github.event.action == 'published' && github.event.release.author.login != 'mergify-ci-bot' - needs: assert-binaries-present - uses: ./.github/workflows/build-wheels.yml - with: - stamp-version: true - - publish: - name: publish to PyPI - if: github.event_name == 'release' && github.event.action == 'published' && github.event.release.author.login != 'mergify-ci-bot' - runs-on: ubuntu-26.04 - needs: - - assert-binaries-present - - build-wheels-for-publish - environment: release - permissions: - id-token: write - contents: write - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: wheel-* - merge-multiple: true - path: dist - - # Everything in `dist` goes to PyPI, so this is the last place - # to stop an sdist: maturin vendors the whole workspace into - # one, and uploading it would publish the full CLI source. - # Anything but a wheel fails the job before the upload. - - name: Check only wheels are collected - shell: bash - run: | - set -euo pipefail - ls -la dist - mapfile -t others < <(find dist -type f ! -name '*.whl' -printf '%f\n') - if (( ${#others[@]} > 0 )); then - echo "::error::refusing to publish non-wheel files: ${others[*]}" - exit 1 - fi - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 - with: - packages-dir: dist - # Mint PEP 740 attestations for the wheels via the - # same Trusted Publishing identity (id-token: write above). - attestations: true diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 2f7896d1..00000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/AGENTS.md b/AGENTS.md index 1d422af1..1a079076 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,279 +1,49 @@ # Agent Instructions -`mergify` is a native Rust CLI — a Cargo workspace under `crates/`, with the -binary built from `crates/mergify-cli` (`[[bin]] name = "mergify"`). There is no -Python: the port is complete and every command runs in-process. If you find a -doc, comment, or rule mentioning `console_error`, `click`, `DYMGroup`, -`mergify_cli.dym`, `crates/mergify-py-shim`, or a "Python shim", it is stale — -fix it. +This repository is the public home of the `mergify` CLI. It doesn't hold the +CLI's source: the CLI is developed, built and released from Mergify's private +monorepo, and so are the files this repository publishes. -The bar is ruff/uv-grade Rust: typed errors, no panics in library code, clippy -pedantic clean, snapshot-tested output. Concrete rules follow. +## Published files: don't edit them here -## Workspace layout +These are copied from the CLI's source (`cli/` in the private monorepo) as of +the Latest release, by a pull request from `mergify-ci-bot` (branch +`public-files-sync`) that ci-bot's `public-releases-sync` opens whenever they +differ: -- `mergify-core` — shared foundations: `CliError`/`ExitCode`, the HTTP `Client`, - `auth`, `Output`, `env`. -- `mergify-tui` — terminal primitives: `Theme` (color), glyphs, relative time. - Stays dependency-light and **clap-free**. -- `mergify-cli` — the binary: clap tree, dispatch, `run_native`, `self_update`, - `cli_schema`. -- `mergify-stack` / `mergify-ci` / `mergify-queue` / `mergify-events` / - `mergify-freeze` / `mergify-config` — one crate per command group. - `mergify-events` also hosts the shared `/logs` client the `queue` - crate's dequeue diagnosis consumes. -- `mergify-test-support` — shared test scaffolding (not published). - -## Error handling - -Library and command code **never prints errors and never calls `process::exit`**. -Return `Result<_, mergify_core::CliError>`. `main()` is the single error sink: it -writes `mergify: {err}` to stderr, walks the `source()` chain printing each cause -as a `caused by:` line, and exits with `err.exit_code()`. - -`CliError` is a typed enum; each variant maps to a stable `ExitCode` -(`GitHubApi`→5, `MergifyApi`→6, `Configuration`→7, … see `exit_code.rs`). Pick the -variant whose exit code matches the failure class. Never add a `String` catch-all -for a new failure category that deserves its own code — add a variant. - -```rust -// GOOD — typed variant, lowercase message, no prefix (the sink adds "mergify:") -return Err(CliError::StackNotFound(format!("branch {name} not found"))); - -// BAD — printing + exiting inside a command -eprintln!("error: branch not found"); -std::process::exit(1); - -// BAD — capitalized / pre-prefixed (the sink double-prefixes it) -return Err(CliError::Generic("Error: branch not found".into())); -``` - -**Preserve causes — don't flatten them into a string.** Prefer keeping a typed -cause over `format!("...: {e}")`: - -- self-describing typed error → `CliError::Source` (or `?` it through the - generated `From>`), which keeps it transparently. -- "doing X failed because of Y" → `CliError::wrap(context, e)`, which shows the - context as the headline and `e` as a `caused by:` line. - -```rust -// GOOD — context + preserved cause, prints a `caused by:` line -let exe = std::env::current_exe().map_err(|e| CliError::wrap("locate binary", e))?; - -// AVOID — flattens the cause; reach for Generic only when there's no typed cause -let exe = std::env::current_exe() - .map_err(|e| CliError::Generic(format!("locate binary: {e}")))?; -``` - -**No `anyhow` / `eyre` / `miette`.** Deliberate house stance: libraries return the -typed `CliError`; the binary prints it (and the chain). If you need a richer -rendered report, extend `CliError` + the `main()` sink in the PR — don't add -`anyhow`. - -**API calls go through `mergify_core::http::Client`** with the right `ApiFlavor`, -which assigns the correct exit code and gets retries (5xx, 429/`Retry-After` -rate-limits), timeouts, and the `User-Agent` for free. **Never `use reqwest` -directly from a command crate** (the one exception is `self_update`, documented in -place: it does unauthenticated cross-host binary downloads the JSON client can't -model). - -## No panics in library / command paths - -A panic is a crash with no exit-code contract. In non-test code do not -`.unwrap()`, `.expect()`, `panic!`, `unreachable!`, or `todo!` on anything derived -from user input, the network, the filesystem, or git output. Return a `CliError`. - -```rust -// BAD -let n = pull["number"].as_u64().unwrap(); -// GOOD -let n = pull["number"].as_u64() - .ok_or_else(|| CliError::Generic("pull payload missing `number`".into()))?; -``` - -**Allowed exceptions** (must be provably infallible; add a one-line comment why): -`Regex::new` on a literal behind `LazyLock`; `write!`/`writeln!` into a `String`; -a mandatory regex capture group after the overall match succeeded; an -`unreachable!` arm for a case handled earlier in the same function (e.g. the -pre-runtime introspection commands). Prefer encoding an invariant in the type so -the `expect` disappears; a stringly `match … => panic!("unknown X")` is **not** -allowed — use an exhaustive `enum` match. - -## clap conventions - -- Every command group is a derive subcommand enum: `Group(GroupArgs)` where - `GroupArgs` holds `#[command(subcommand)] command: GroupSubcommand`. Do **not** - hand-roll a `trailing_var_arg` catch-all and re-parse argv — clap gives correct - usage, help, did-you-mean, and global-flag inheritance for free. (`stack` is a - normal subcommand group; if you see a "shim"/`ShimmedArgs`, it's a regression.) -- Register every new `(group, subcommand)` pair in `NATIVE_COMMANDS` in - `main.rs` — it's the single source of truth behind `--list-native-commands`, - and the schema golden asserts the `stack` set matches it. Adding a command - without updating it makes the test fail. -- Global flags (`--debug`, `-v/--verbose`, `--color`) are `#[arg(global = true)]` - so they work on every subcommand. -- Pure-introspection commands (`completions`, `_internal dump-cli-schema`, - `_internal man`) are handled before the tokio runtime starts. - -## stdout / stderr / `--json` / color - -- **Machine output (JSON) → stdout. Human chatter, progress, prompts, errors, - and logs → stderr.** Piping stdout into `jq` must stay clean. -- Commands emit results through `&mut dyn mergify_core::output::Output`, not - `println!`. Call `emit` / `emit_json_value` once. -- **Color goes through `mergify_tui::theme`**, never hardcoded SGR escapes. The - precedence is `--color `, then `NO_COLOR`, then - `FORCE_COLOR`/`CLICOLOR_FORCE`, then the TTY, and it is resolved once at the - CLI entry point: `resolve_color_choice` in `main.rs` folds the three variables - into the `ColorChoice` it hands `set_color_choice`. `mergify-tui` itself reads - no environment variable, which is why a consumer crate's tests no longer touch - the environment just by rendering a themed line. Cursor-movement / erase - escapes in the progress renderer are not color and are fine. - -```rust -// GOOD -writeln!(w, "{}created.{}", theme.green.render(), theme.green.render_reset())?; -// BAD -writeln!(w, "\x1b[32mcreated.\x1b[0m")?; -``` - -## Terminal output symbols - -Compact, single-width Unicode only — never emoji (they render at inconsistent -widths and break column alignment). - -| Symbol | Meaning | +| Here | What it is | |---|---| -| `✓` | success, approved, merged | -| `✗` | failure, conflict, changes requested | -| `●` | active, pending, in progress | -| `○` | inactive, skipped, none | -| `—` | unknown, not applicable | - -**Forbidden in terminal output:** `✅ ❌ 🟢 🔴 ⏰ ⏳ ⚠️ 🔄 📦` and other emoji. -**Emoji are allowed only in** Markdown destined for GitHub (PR comments, CI -summaries) and CI-log output (`ci junit-process`, `ci scopes`) read in CI runners. - -Success messages end with a period ("Stack reordered successfully.") in the -theme's green, applied inside the human-render closure. - -## Logging - -Structured logs use `tracing`, emitted to **stderr** by the subscriber `main()` -installs. `-v`/`-vv`/`-vvv` map to info/debug/trace; `--debug` floors at debug; -`RUST_LOG` overrides. Only our own crates are raised, so dep noise stays quiet. -Instrument network/process boundaries at `debug`; **never log the bearer token** -or any auth header. - -## HTTP / API rules - -- Match the endpoint's real response shape. Empty-body success → use - `post_no_response` / `delete_if_exists`, never `let _: serde_json::Value = - client.post(...)` (that fails to deserialize an empty body). -- Fields the server may omit/null are `Option` with `#[serde(default)]`. -- Build one `reqwest::Client` per command run and reuse it across same-host calls - (it owns the connection pool); don't rebuild per request. - -## Testing - -A change is not done without a test. Use the highest-fidelity tool per layer. - -- **Snapshot the output that *is* the product** with `insta`. The CLI schema has - a golden (`cli_schema_golden`, version redacted) that catches any flag / - subcommand / help / value-hint drift; accept intentional changes with - `cargo insta review`. Add insta goldens for colorized human renders (a fixed - `now` seam and `cfg!(test)` color-off make them deterministic). Keep exact - `assert_eq!` on `serde_json::Value` for `--json` payloads — stronger than a - string snapshot. -- **HTTP commands: wiremock with payload + call-count fidelity.** Assert - `body_json(...)` and `.expect(N)`, not just "didn't crash". Stub an empty-body - endpoint with `ResponseTemplate::new(200)` — never `set_body_json(json!({}))`, - which masks the empty-body bug the rule exists to catch. -- Pin exact exit codes for the `CliError` contract; add a regression test for - every fixed failure mode (exit code, message, missing-field tolerance). -- **Nothing mutates the process environment, tests included.** Read it through - `mergify_core::env` (`var`, `var_os`, `var_non_empty`); give the code under - test one with `mergify_core::env::testing::with_vars`, which installs a - thread-local overlay and touches no global state. `clippy.toml` disallows - `std::env::var*`, `set_var` and `remove_var`, and `deny.toml` bans - `temp-env`. - - Why, in one line: `setenv` racing `getenv` on another thread is a - use-after-free, libtest runs tests on many threads, and the concurrent - reader is usually not even ours (`std::env::temp_dir` behind every - `tempfile::tempdir()`, `Command::spawn` building a child's environment). - `crates/mergify-core/src/env.rs` carries the full argument and the - overlay's limits — read it before reaching for an exception. - - Two consequences worth knowing before you write the test. An overlay **is** - the environment while installed, so a variable you do not list reads as - unset whatever the host exports: name what the case is about and nothing - else, and it behaves the same on a laptop and on a CI runner. And it covers - our reads only — a dependency, or a process you spawn, still sees the real - environment. Giving a **child** a variable is a different job with a - different tool, `Command::env`, and is untouched by any of this. - -## Dependencies - -Shared third-party deps live in the root `[workspace.dependencies]` table; each -crate references them as `dep = { workspace = true }` (add extra features with -`features = [...]`, which are additive). When adding or bumping a shared dep, edit -the **root table**, not the crate — that's the single source of truth. - -- TLS is `rustls` everywhere (`default-features = false, features = ["rustls"]`). - Never pull in `native-tls`/`openssl`. -- Internal crates are `{ path = "../…" }` path deps. -- `cargo-deny` gates advisories, licenses, bans, and sources (`deny.toml`); a new - dep with a non-allowed license fails CI — add the SPDX id (with the crate that - needs it) rather than loosening the policy. - -## clippy / fmt / MSRV bar - -- CI runs `cargo fmt --all --check` and - `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings`. - **Warnings fail the build.** Run both before pushing. -- Lints are `clippy::all` + `clippy::pedantic` (warn) with a short allow-list. Do - not add `#[allow(...)]` without a one-line reason, and don't grow the workspace - allow-list without justifying it in the PR. -- MSRV is `rust-version = "1.88"`, gated by a CI job that compiles on exactly that - toolchain. Don't use features stabilized after it unless you bump - `rust-version` (and say so in the commit) — the job will catch it. - -## Documentation - -Doc updates ship in the **same** commit/PR as the change, never a follow-up. - -1. **README.md** — it documents the CLI contract at group altitude: the - top-level command groups, global options, the token/repository/api-url - resolution order, environment variables, and exit codes. Per-subcommand - detail deliberately lives in `--help` and docs.mergify.com, not here — don't - re-add an exhaustive subcommand list. When you change that surface — - add/rename/remove a command group, add or alter a global flag or an env - fallback, add an exit code — update the matching section in the same PR. The - `cli_schema_golden` test guards the schema snapshot, **not** the README prose. -2. **Skills** (`skills/`) — update the *matching* skill (don't create a - duplicate). Directory names aren't 1:1 with command groups: - - | command group | skill dir | - |---|---| - | `stack` | `mergify-stack` | - | `ci`, `tests` | `mergify-ci` | - | `config` | `mergify-config` | - | `queue` | `mergify-merge-queue` | - | `events` | `mergify-events` | - | `freeze` | `mergify-merge-protections` | - -3. **Crate `//!` module docs** — keep the purpose/invariant header accurate when - you change a module's contract (the `Output` stdout-purity invariant, the - `CliError`→`ExitCode` mapping, the HTTP retry policy). - -## Commits & PRs - -- Conventional-commit subjects scoped by area: `feat(stack):`, `fix(ci):`, - `chore(deps):`, `feat(cli):`. Imperative, no trailing period. The commit body - becomes the PR body — put ticket refs and context there. -- Never commit on `main`; never `git push` — use `mergify stack push`. Each - commit becomes its own PR and must pass CI **independently** (compile, clippy - `-D warnings`, tests). Fold lint/fmt fixes into the commit that caused them. -- Small in-flight polish → `git commit --amend` (+ `mergify stack note -m "why"` - before re-pushing), not a pile of fixup commits. +| `README.md` | the CLI's user documentation (also its PyPI page) | +| `install.sh` | the installer, served from `main` to `curl … \| sh` | +| `skills/` | the agent skills (`npx skills add Mergifyio/mergify-cli`) | +| `.claude-plugin/`, `.codex-plugin/`, `assets/` | the plugin manifests and icon | +| `scripts/check-skills.py` | the skill check CI runs | + +A change made to them here is overwritten by the next sync, so CI's +`synced-files` check fails any pull request that edits them, unless it comes +from `mergify-ci-bot`. Suggestions are welcome as issues, or as pull requests +that Mergify carries over to the CLI's source. + +## Owned here + +- `.github/workflows/ci.yaml`, `.mergify.yml`, `renovate.json`, + `_typos.toml`, this `AGENTS.md` and `LICENSE`. +- The releases, written by ci-bot's mirror, never by hand. A release carrying + the CLI binaries is Latest, which install.sh, `mergify self-update`, the + Homebrew tap and the docs' CLI reference all follow. +- The issue tracker. + +## Checks + +CI (`.github/workflows/ci.yaml`, gated by `ci-gate`) runs against the latest +release, since nothing is built here: + +- `skills`: `scripts/check-skills.py` checks every skill's front matter and + that every `mergify ` it mentions exists in the latest + release's binary. +- `install-script-release`: shellcheck, then install.sh end to end against + the latest release's real files served from a local fixture + (`MERGIFY_BASE_URL`), on Linux, macOS and Windows, including the tampered + `SHA256SUMS` cases. +- `synced-files`: the published files only change through the sync. +- `typos`, with the allow-list in `_typos.toml`. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 064adbca..00000000 --- a/Cargo.lock +++ /dev/null @@ -1,4002 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" -dependencies = [ - "cipher", - "cpubits", - "cpufeatures 0.3.0", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "apple-native-keyring-store" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" -dependencies = [ - "keyring-core", - "log", - "security-framework", -] - -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block-padding" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borrow-or-share" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cbc" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" -dependencies = [ - "cipher", -] - -[[package]] -name = "cc" -version = "1.2.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" -dependencies = [ - "crypto-common 0.2.2", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_complete" -version = "4.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" -dependencies = [ - "clap", -] - -[[package]] -name = "clap_derive" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "clap_mangen" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" -dependencies = [ - "clap", - "roff", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "console" -version = "0.16.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix", - "windows-sys 0.61.2", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "deadpool" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" -dependencies = [ - "deadpool-runtime", - "lazy_static", - "num_cpus", - "tokio", -] - -[[package]] -name = "deadpool-runtime" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" - -[[package]] -name = "dialoguer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" -dependencies = [ - "console", - "fuzzy-matcher", - "shell-words", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", - "ctutils", -] - -[[package]] -name = "dirs" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "email_address" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" -dependencies = [ - "serde", -] - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fancy-regex" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "fluent-uri" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" -dependencies = [ - "borrow-or-share", - "ref-cast", - "serde", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fraction" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" -dependencies = [ - "num", - "num-bigint", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fuzzy-matcher" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" -dependencies = [ - "thread_local", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "h2" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inout" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" -dependencies = [ - "block-padding", - "hybrid-array", -] - -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "pest", - "pest_derive", - "serde", - "similar", - "tempfile", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "jsonschema" -version = "0.58.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea18b8d5e1469b1bdd6b349169305141316a7c26da27f239773b65c830c0f5d6" -dependencies = [ - "ahash", - "bytecount", - "data-encoding", - "email_address", - "fancy-regex", - "fraction", - "getrandom 0.3.4", - "itoa", - "jsonschema-regex", - "jsonschema-value", - "num-cmp", - "num-traits", - "percent-encoding", - "referencing", - "regex", - "serde", - "serde_json", - "strum", - "unicode-general-category", - "uuid-simd", -] - -[[package]] -name = "jsonschema-regex" -version = "0.58.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854e9e22c420535035e95eed240cbfb4a5f4b7da74d122d63144e0c31926dbfd" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "jsonschema-value" -version = "0.58.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3bfac11ac357ec620880025757d38c67da8fd5b9ef5024d19f0f360002b4ca" -dependencies = [ - "ahash", - "bytecount", - "fraction", - "getrandom 0.3.4", - "num-cmp", - "num-traits", - "serde_json", - "zmij", -] - -[[package]] -name = "keyring" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - -[[package]] -name = "keyring-core" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" -dependencies = [ - "log", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" -dependencies = [ - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mergify-auth" -version = "0.0.0" -dependencies = [ - "chrono", - "mergify-core", - "mergify-test-support", - "mergify-tui", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing", - "url", - "wiremock", -] - -[[package]] -name = "mergify-ci" -version = "0.0.0" -dependencies = [ - "chrono", - "flate2", - "getrandom 0.4.3", - "glob", - "mergify-config", - "mergify-core", - "mergify-test-support", - "opentelemetry-proto", - "prost", - "quick-xml", - "regex", - "reqwest", - "serde", - "serde_json", - "serde_yaml_ng", - "tempfile", - "tokio", - "url", - "wiremock", -] - -[[package]] -name = "mergify-cli" -version = "0.0.0" -dependencies = [ - "chrono", - "clap", - "clap_complete", - "clap_mangen", - "insta", - "mergify-auth", - "mergify-ci", - "mergify-config", - "mergify-core", - "mergify-events", - "mergify-freeze", - "mergify-queue", - "mergify-stack", - "mergify-tui", - "regex", - "reqwest", - "self-replace", - "serde", - "serde_json", - "serde_yaml_ng", - "sha2 0.11.0", - "tempfile", - "tokio", - "tracing", - "tracing-subscriber", - "url", - "wiremock", -] - -[[package]] -name = "mergify-config" -version = "0.0.0" -dependencies = [ - "jsonschema", - "mergify-core", - "mergify-test-support", - "serde", - "serde_json", - "serde_yaml_ng", - "tempfile", - "tokio", - "url", - "wiremock", -] - -[[package]] -name = "mergify-core" -version = "0.0.0" -dependencies = [ - "chrono", - "dirs", - "keyring", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror", - "tokio", - "tracing", - "url", - "wiremock", -] - -[[package]] -name = "mergify-events" -version = "0.0.0" -dependencies = [ - "anstyle", - "chrono", - "mergify-core", - "mergify-test-support", - "mergify-tui", - "serde", - "serde_json", - "thiserror", - "tokio", - "url", - "wiremock", -] - -[[package]] -name = "mergify-freeze" -version = "0.0.0" -dependencies = [ - "anstyle", - "chrono", - "iana-time-zone", - "mergify-core", - "mergify-test-support", - "mergify-tui", - "serde", - "serde_json", - "tokio", - "wiremock", -] - -[[package]] -name = "mergify-queue" -version = "0.0.0" -dependencies = [ - "anstyle", - "chrono", - "mergify-core", - "mergify-events", - "mergify-test-support", - "mergify-tui", - "serde", - "serde_json", - "tokio", - "url", - "wiremock", -] - -[[package]] -name = "mergify-stack" -version = "0.0.0" -dependencies = [ - "chrono", - "mergify-core", - "mergify-tui", - "regex", - "serde", - "serde_json", - "tempfile", - "terminal_size", - "tokio", - "tracing", - "unicode-width", - "url", - "wiremock", -] - -[[package]] -name = "mergify-test-support" -version = "0.0.0" -dependencies = [ - "mergify-core", -] - -[[package]] -name = "mergify-tui" -version = "0.0.0" -dependencies = [ - "anstyle", - "chrono", - "console", - "ctrlc", - "dialoguer", -] - -[[package]] -name = "micromap" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-cmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "opentelemetry" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror", -] - -[[package]] -name = "opentelemetry-proto" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d" -dependencies = [ - "opentelemetry", - "opentelemetry_sdk", - "prost", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry", - "percent-encoding", - "portable-atomic", - "rand 0.9.4", - "thiserror", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2 0.10.9", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "quick-xml" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "referencing" -version = "0.58.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590efadb0a669f1712c1e3ab810a55d4c3d20d0127c455f7236dd0710aba0346" -dependencies = [ - "ahash", - "fluent-uri", - "getrandom 0.3.4", - "hashbrown", - "itoa", - "micromap", - "parking_lot", - "percent-encoding", - "serde_json", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" -dependencies = [ - "base64 0.23.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "roff" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "secret-service" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" -dependencies = [ - "aes", - "cbc", - "futures-util", - "getrandom 0.4.3", - "hkdf", - "hybrid-array", - "num", - "once_cell", - "serde", - "sha2 0.11.0", - "zbus", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "self-replace" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" -dependencies = [ - "fastrand", - "tempfile", - "windows-sys 0.52.0", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_yaml_ng" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "thread_local", - "tracing", - "tracing-core", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" -dependencies = [ - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "uuid-simd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" -dependencies = [ - "outref", - "vsimd", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "wiremock" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" -dependencies = [ - "assert-json-diff", - "base64 0.22.1", - "deadpool", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-util", - "log", - "once_cell", - "regex", - "serde", - "serde_json", - "tokio", - "url", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zlib-rs" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 99c89f2a..00000000 --- a/Cargo.toml +++ /dev/null @@ -1,91 +0,0 @@ -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# - -[workspace] -resolver = "3" -members = ["crates/*"] - -[workspace.package] -edition = "2024" -# MSRV: minimum supported toolchain. 1.88 is the floor — the code -# uses let-chains (`if let Some(x) = … && cond`), stabilized in Rust -# 1.88. The msrv CI job pins this exact version; move both together. -rust-version = "1.88" -license = "Apache-2.0" -repository = "https://github.com/Mergifyio/mergify-cli" -authors = ["Mergify Team "] - -[workspace.dependencies] -anstyle = "1" -chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } -clap = { version = "4.5", features = ["derive", "env"] } -clap_complete = "4.5" -clap_mangen = "0.3" -console = "0.16" -ctrlc = "3" -dialoguer = { version = "0.12", default-features = false, features = ["fuzzy-select"] } -dirs = "7" -flate2 = "1" -getrandom = "0.4" -glob = "0.3" -iana-time-zone = "0.1" -jsonschema = { version = "0.58", default-features = false } -# OS credential store for the token `mergify auth login` mints. The -# default feature set is the one that keeps the Linux build free of C: -# `zbus-secret-service-keyring-store` talks to the Secret Service over -# pure-Rust D-Bus, so the manylinux wheel needs no libsecret at build -# time and the user's machine needs no `.so` at run time. macOS and -# Windows use their native stores. Every one of them can be absent — -# a container has no D-Bus session — and the store falls back to a -# 0600 file, which is why no keychain failure is fatal. -keyring = "4" -opentelemetry-proto = { version = "0.33", default-features = false, features = ["gen-tonic-messages", "trace"] } -prost = "0.14" -quick-xml = "0.42" -regex = "1" -# `form`: the OAuth device grant is `application/x-www-form-urlencoded` -# on the wire, as RFC 6749 §4 requires. Nothing else in the CLI sends a -# form. -reqwest = { version = "0.13", default-features = false, features = ["rustls", "json", "form"] } -self-replace = "1" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml_ng = "0.10" -sha2 = "0.11" -tempfile = "3.14" -terminal_size = "0.4" -thiserror = "2.0" -# tokio: rt-multi-thread is intentionally NOT here — it is added only -# in the dev-dependency entries that need it, so the shipped binary's -# single current-thread runtime never pulls in the MT scheduler. -tokio = { version = "1", default-features = false, features = ["macros", "rt", "time"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "env-filter", "ansi"] } -unicode-width = "0.2" -url = "2" -insta = { version = "1", features = ["json", "redactions"] } -wiremock = "0.6" - -[workspace.lints.rust] -unsafe_code = "forbid" - -[workspace.lints.clippy] -all = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -# Allow-list: pedantic rules that fire too aggressively for a CLI. -# Keep this list intentional and short; revisit per-PR as we learn. -module_name_repetitions = "allow" -missing_errors_doc = "allow" -missing_panics_doc = "allow" - -[profile.release] -lto = "thin" -codegen-units = 1 -strip = "symbols" diff --git a/RELEASING.md b/RELEASING.md deleted file mode 100644 index 462e3b46..00000000 --- a/RELEASING.md +++ /dev/null @@ -1,73 +0,0 @@ -# Releasing `mergify-cli` - -Two-stage flow keyed off the immutable-releases policy on -`Mergifyio/*` — once a release is published, asset metadata is -locked. So binaries are attached to the release **as a draft** -before the maintainer clicks Publish. - -## Stage 1 — build the draft - -1. Go to **Actions** → **release** → **Run workflow** (top right of - the workflow page). -2. Leave **tag** empty to auto-pick `YYYY.M.` (this UTC - month + one past the highest version already taken this month, - pending drafts included; `1` if nothing has shipped this month - yet). Override it only if you need a specific version (e.g. - cherry-pick onto an older line); an explicit tag must be - `YYYY.M.N` and not already taken. Leave - **target_commitish** empty unless you're cherry-picking a - release commit off an older branch. -3. Click **Run workflow**. - -The workflow builds the wheel matrix (Linux x86_64/aarch64 for both -glibc and musl, macOS x86_64/aarch64, Windows x86_64), extracts the -`mergify` binary out of each glibc/macOS/Windows wheel (the musl -wheels only go to PyPI), packages -`mergify--.{tar.gz,zip}` + `SHA256SUMS`, dumps the CLI schema to `cli-schema.json` (rendered -by the docs site into the command reference), and runs `gh release -create --draft --generate-notes` to create the release with -the assets attached and notes auto-generated from PRs merged since -the previous tag. Takes ~10 minutes. - -## Stage 2 — review and publish - -1. Go to **Releases** → the new draft. -2. Review the auto-generated notes; edit if needed (drafts are - mutable). -3. Confirm all seven asset names are listed (the binaries are each - prefixed with the release version): - - `mergify--x86_64-unknown-linux-gnu.tar.gz` - - `mergify--aarch64-unknown-linux-gnu.tar.gz` - - `mergify--x86_64-apple-darwin.tar.gz` - - `mergify--aarch64-apple-darwin.tar.gz` - - `mergify--x86_64-pc-windows-msvc.zip` - - `SHA256SUMS` - - `cli-schema.json` -4. Click **Publish release**. - -Publishing fires `release: published`, which kicks the second half -of the workflow: assert all seven assets are present, rebuild wheels -with the same version stamp, and publish to PyPI through -Trusted-Publisher. Takes ~10 minutes. - -## Do not - -- **Don't click "Draft a new release" in the Releases UI.** That - path creates a draft without binaries; once you publish it the - release is immutable and the asset assertion will fail, blocking - PyPI. To recover you'd have to delete the release and re-run - stage 1 with the same tag. -- **Don't run `gh release create` from your laptop.** The - workflow does this so the binary build is reproducible and the - PyPI Trusted-Publisher identity matches the tag. - -## If stage 2 fails - -The release is already published and immutable. If the assert job -fails (binaries missing) or the PyPI publish fails (transient -PyPI outage, version conflict, etc.): - -- **Asset assertion failed** — someone bypassed the workflow. - Delete the release, re-run stage 1. -- **PyPI publish failed** — re-run just the `publish` job from - Actions. The wheels were built; PyPI is the only step left. diff --git a/_typos.toml b/_typos.toml index 1b1224be..b37e9ad1 100644 --- a/_typos.toml +++ b/_typos.toml @@ -1,18 +1,6 @@ # typos spell-check allow-list. Reproduce CI locally with `typos`. [default.extend-words] -# Spelling used consistently across the crates, including the -# queue::Unparseable variant — accept it rather than churn a rename. +# The CLI's spelling (its exit-code table in the README says "unparseable"), +# kept as is rather than diverging from the binary's own messages. unparseable = "unparseable" -# Hyphenated prefix in comments: "mis-configured", "mis-resolved", -# "mis-indent" — typos splits on the hyphen and flags "mis". -mis = "mis" -# Relative-time format tokens: Nm / Nh / Nd (N minutes/hours/days). -nd = "nd" -# French input exercised by the slug tests ("l'authentification"). -authentification = "authentification" -# Hex-like test Change-Id fixture token ("Idead0000…ff"). -idead = "idead" -# Latin-1 "café" fixture with the é as a raw byte ("caf\xe9.txt") or -# its replacement char ("caf\u{FFFD}.txt"). -caf = "caf" diff --git a/clippy.toml b/clippy.toml deleted file mode 100644 index 19891017..00000000 --- a/clippy.toml +++ /dev/null @@ -1,27 +0,0 @@ -# The process environment is read through `mergify_core::env` and -# written nowhere. `crates/mergify-core/src/env.rs` carries the -# argument; these entries carry the replacement. -# -# `set_var` / `remove_var` are `unsafe fn`, so the workspace's -# `unsafe_code = "forbid"` is what actually stops a mutation — these -# two lines are documentation, and a contributor who relaxes that lint -# is unlocking more than they think. `set_current_dir` is the safe -# process-global mutator, banned for the same reason: it races every -# relative path in every other test thread. -# -# `std::env::args` and `current_exe` are not the environment and are -# not listed. `temp_dir` is a `getenv` for `TMPDIR` and is unlisted -# for a different reason: it takes no variable name, so there is -# nothing to route through the funnel, and it is a read, which was -# never the unsound half. A build script reads cargo's environment, -# not the process's, and cannot depend on `mergify-core`; -# `crates/mergify-cli/build.rs` allows the lint in place. -disallowed-methods = [ - { path = "std::env::var", reason = "read it through `mergify_core::env::var` / `var_non_empty`" }, - { path = "std::env::var_os", reason = "read it through `mergify_core::env::var_os` / `var_os_non_empty`" }, - { path = "std::env::vars", reason = "read it through `mergify_core::env`; iterating the whole environment is only right when building a child process's, and needs an explicit allow" }, - { path = "std::env::vars_os", reason = "read it through `mergify_core::env`; iterating the whole environment is only right when building a child process's, and needs an explicit allow" }, - { path = "std::env::set_var", reason = "nothing mutates the process environment; give the code under test one with `mergify_core::env::testing::with_vars`" }, - { path = "std::env::remove_var", reason = "nothing mutates the process environment; give the code under test one with `mergify_core::env::testing::with_vars`" }, - { path = "std::env::set_current_dir", reason = "process-global, so it races every relative path in every other test thread; take the directory as a parameter" }, -] diff --git a/crates/mergify-auth/Cargo.toml b/crates/mergify-auth/Cargo.toml deleted file mode 100644 index 7bba6753..00000000 --- a/crates/mergify-auth/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "mergify-auth" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify auth` subcommands." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } -mergify-tui = { path = "../mergify-tui" } -chrono = { workspace = true } -serde = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -# `test-support` brings the credential stores that model one specific -# keychain failure. `logout`'s "removed but never read" branch is -# unreachable without one. -mergify-core = { path = "../mergify-core", features = ["test-support"] } -mergify-test-support = { path = "../mergify-test-support" } -serde_json = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-auth/src/browser.rs b/crates/mergify-auth/src/browser.rs deleted file mode 100644 index c5776570..00000000 --- a/crates/mergify-auth/src/browser.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Putting the verification page in front of the user. -//! -//! A convenience, never a step in the grant. There is no browser on -//! a CI runner, in a container, or at the far end of an SSH session, -//! and `auth login` has to work on all three — so every failure here -//! is a debug line, and the URL is printed whether this succeeds or -//! not. -//! -//! No crate for this: each platform is one process spawn, and the -//! workspace's dependency policy is not worth spending on thirty -//! lines of [`Command`]. - -use std::io; -use std::process::Command; -use std::process::Stdio; - -/// How [`crate::login`] reaches a browser. -/// -/// A trait rather than a free function so the suite can watch the -/// URL go past without opening a window on whoever ran `cargo test`. -pub trait Browser { - /// Show `url`, or say why not. An `Err` is never fatal. - fn open(&self, url: &str) -> io::Result<()>; -} - -/// The user's own browser, through the platform's URL opener. -pub struct SystemBrowser; - -impl Browser for SystemBrowser { - fn open(&self, url: &str) -> io::Result<()> { - launch(command_for(url)?) - } -} - -#[cfg(target_os = "macos")] -fn command_for(url: &str) -> io::Result { - // macOS has no `DISPLAY` to consult, so the SSH variables are - // the only signal that the screen `open` would use is not the - // one the user is looking at. It belongs to whoever is sitting - // at the machine, who did not ask to approve anything and would - // be handed a page with somebody else's user code on it. - // - // The Linux arm deliberately asks a different question rather - // than this one: an SSH session there can have a forwarded - // display, which is the case where opening *is* right, and - // `DISPLAY` is what tells the two apart. macOS has nothing - // equivalent to forward. - if mergify_core::env::var_non_empty("SSH_CONNECTION").is_some() - || mergify_core::env::var_non_empty("SSH_TTY").is_some() - { - return Err(io::Error::other( - "an SSH session: the browser would open on the machine's own screen", - )); - } - let mut command = Command::new("open"); - command.arg(url); - Ok(command) -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn command_for(url: &str) -> io::Result { - // With no graphical session `xdg-open` falls through to a - // terminal browser (`www-browser`, `w3m`, `lynx`), which takes - // over the very terminal the user is reading the code from. A - // headless box and an SSH session are two of the three cases - // this command exists to keep working, so they get the printed - // URL and nothing else. - // - // The display rather than the SSH variables, on purpose: an SSH - // session with X11 forwarding has a display, and it is the - // user's own — refusing it would withhold the browser from the - // one remote case that can show one. The converse, an rc file - // that exports `DISPLAY=:0` unconditionally, then opens on the - // remote machine's monitor; that configuration sends every - // GUI-launching command there and is not ours to second-guess. - if mergify_core::env::var_non_empty("DISPLAY").is_none() - && mergify_core::env::var_non_empty("WAYLAND_DISPLAY").is_none() - { - return Err(io::Error::other( - "no graphical session: neither DISPLAY nor WAYLAND_DISPLAY is set", - )); - } - let mut command = Command::new("xdg-open"); - command.arg(url); - Ok(command) -} - -#[cfg(windows)] -fn command_for(url: &str) -> io::Result { - use std::os::windows::process::CommandExt; - - let mut command = Command::new("cmd"); - // `raw_arg`, because the whole point is the quoting: the - // standard escaping would leave the URL unquoted and `cmd` - // would act on what is in it. - command.arg("/C").raw_arg(windows_start_argument(url)?); - Ok(command) -} - -/// The verbatim `cmd` command line that opens `url`. -/// -/// `start` is a `cmd` builtin, so there is no reaching it except -/// through a shell that re-parses its own command line — and an `&` -/// or a `|` in a URL is a command separator to that shell. -/// `--api-url` decides which host writes that URL, which makes this -/// the same threat `device::checked_uri` exists for. Quoting the URL -/// makes those characters literal, and that is only sound because -/// the URL cannot carry a quote of its own: it came through -/// `Url::parse`, which percent-encodes `"` in every component it can -/// appear in. Anything that proves otherwise is refused rather than -/// handed to `cmd`. -/// -/// A `%` is refused for the same reason, and it is the subtler one: -/// `cmd` expands `%NAME%` on its command line *after* this check has -/// run, inside the quotes as well as outside, and a command line has -/// no escape for it (`%%` only works in a batch file). So a URL -/// whose percent-escapes happen to bracket a variable name opens a -/// different page than the one printed — the drift `verification_url` -/// exists to prevent — and a variable whose value holds a `"` closes -/// the quote this function just proved could not be closed. Refusing -/// costs a browser that would have opened; the URL is printed either -/// way. -/// -/// The empty `""` is `start`'s window-title argument. Without it -/// `start` reads the quoted URL as the title and opens nothing. -/// -/// Compiled on every platform so the suite can pin it anywhere; only -/// Windows calls it. -#[cfg(any(windows, test))] -fn windows_start_argument(url: &str) -> io::Result { - if url.contains(['"', '%']) || url.chars().any(char::is_control) { - return Err(io::Error::other( - "the verification URL holds a character this client will not hand to cmd", - )); - } - Ok(format!("start \"\" \"{url}\"")) -} - -fn launch(mut command: Command) -> io::Result<()> { - // Whatever the opener has to say is not the user's problem: the - // browser opens or it does not, and the URL is printed either - // way. Its output would land in the middle of the code the user - // is trying to read. - let mut child = command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn()?; - // The opener exits as soon as the browser has the URL, but - // `login` then sits in the poll loop for minutes. A child nobody - // waits on is a zombie for all of it. - // - // `Builder::spawn` rather than `thread::spawn`, which panics - // when the OS refuses a thread. This module promises the login - // proceeds whatever happens here, and a panic would take the - // login down with it — a zombie until the process exits is the - // cheaper of the two failures. - let reaper = std::thread::Builder::new().spawn(move || match child.wait() { - // What the opener made of the URL is its own business, but a - // non-zero exit is the only sign the page never opened, and - // `open` reports "no application knows this URL" that way - // and nowhere else — its stderr went to /dev/null above. - Ok(status) if !status.success() => { - tracing::debug!(%status, "the browser opener exited with an error"); - } - Ok(_) => {} - Err(e) => tracing::debug!(error = %e, "could not wait for the browser opener"), - }); - if let Err(e) = reaper { - tracing::debug!(error = %e, "could not reap the browser opener"); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // A `&` in a query string is ordinary, and to `cmd` it is a - // command separator. It has to end up inside the quotes. - #[test] - fn the_windows_command_line_quotes_the_url() { - assert_eq!( - windows_start_argument("https://dashboard.mergify.com/device?user_code=BCDF-GHJK&x=1") - .unwrap(), - "start \"\" \"https://dashboard.mergify.com/device?user_code=BCDF-GHJK&x=1\"", - ); - } - - // The quoting above is the only thing between a hostile - // `--api-url` and `cmd`, so a URL that could close the quote is - // not opened at all. - #[test] - fn a_url_carrying_a_quote_is_not_handed_to_cmd() { - assert!(windows_start_argument("https://evil.example/\"&calc").is_err()); - assert!(windows_start_argument("https://evil.example/\r\nhi").is_err()); - } - - // `cmd` expands `%NAME%` after this check, and the value it - // splices in is not held to the rule the check just applied. - #[test] - fn a_url_carrying_a_percent_is_not_handed_to_cmd() { - assert!(windows_start_argument("https://evil.example/?a=%SOMEVAR%").is_err()); - } - - // The whole "never fail the login" promise rests on this - // returning rather than panicking when the opener is not there - // — the CI runner and the container both reach it that way. - #[test] - fn an_opener_that_is_not_installed_is_an_error_not_a_panic() { - assert!(launch(Command::new("mergify-no-such-url-opener")).is_err()); - } - - #[cfg(unix)] - #[test] - fn launching_an_opener_that_exists_succeeds() { - launch(Command::new("true")).unwrap(); - } - - // An opener that starts and then refuses the URL is the one - // failure the caller cannot see: `launch` has already returned - // `Ok` by the time the child exits, and the reaper only has a - // `debug!` to say so with. What this pins is that the reaper - // handles that status instead of unwinding on it. - #[cfg(unix)] - #[test] - fn an_opener_that_exits_non_zero_still_lets_the_login_proceed() { - launch(Command::new("false")).unwrap(); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_opens_the_url_with_open() { - let command = mergify_core::env::testing::with_vars( - [("SSH_CONNECTION", None::<&str>), ("SSH_TTY", None::<&str>)], - || command_for("https://dashboard.mergify.com/device"), - ) - .unwrap(); - assert_eq!(command.get_program(), "open"); - assert_eq!( - command.get_args().collect::>(), - ["https://dashboard.mergify.com/device"], - ); - } - - #[cfg(all(unix, not(target_os = "macos")))] - #[test] - fn a_graphical_session_gets_xdg_open() { - let command = mergify_core::env::testing::with_vars( - [("DISPLAY", Some(":0")), ("WAYLAND_DISPLAY", None::<&str>)], - || command_for("https://dashboard.mergify.com/device"), - ) - .unwrap(); - assert_eq!(command.get_program(), "xdg-open"); - assert_eq!( - command.get_args().collect::>(), - ["https://dashboard.mergify.com/device"], - ); - } - - // Over SSH to a Mac, `open` reaches the screen of whoever is - // sitting at that machine, not the person who ran the command. - #[cfg(target_os = "macos")] - #[test] - fn an_ssh_session_to_a_mac_opens_nothing() { - let opened = mergify_core::env::testing::with_vars( - [ - ("SSH_CONNECTION", Some("10.0.0.1 52000 10.0.0.2 22")), - ("SSH_TTY", None), - ], - || command_for("https://dashboard.mergify.com/device").is_ok(), - ); - assert!( - !opened, - "an SSH session must not open a browser on the host" - ); - } - - // The SSH session the device grant exists for: `xdg-open` here - // would hand the URL to a terminal browser and eat the terminal. - #[cfg(all(unix, not(target_os = "macos")))] - #[test] - fn a_headless_session_opens_nothing() { - let opened = mergify_core::env::testing::with_vars( - [("DISPLAY", None::<&str>), ("WAYLAND_DISPLAY", None::<&str>)], - || command_for("https://dashboard.mergify.com/device").is_ok(), - ); - assert!(!opened, "a session with no display must not run xdg-open"); - } -} diff --git a/crates/mergify-auth/src/device.rs b/crates/mergify-auth/src/device.rs deleted file mode 100644 index 4bff2b9c..00000000 --- a/crates/mergify-auth/src/device.rs +++ /dev/null @@ -1,899 +0,0 @@ -//! The OAuth 2.0 device authorization grant (RFC 8628), client side. -//! -//! Three unauthenticated endpoints on the Mergify API: -//! -//! | Step | Endpoint | -//! |---|---| -//! | Ask for a pair of codes | `POST /v1/oauth/device/code` | -//! | Poll until the owner approves | `POST /v1/oauth/token` | -//! | Give the credential back (RFC 7009) | `POST /v1/oauth/revoke` | -//! -//! The device grant rather than a browser redirect because the CLI -//! runs where there is no browser to redirect to and no port to -//! listen on: over SSH, in a container, on a build machine. The -//! user reads a code off the terminal and approves it wherever they -//! already have a Mergify session. -//! -//! Everything the flow needs comes from the server's own -//! authorization response — the verification URL included. A -//! self-hosted deployment answers with its own dashboard, so a -//! client that hardcoded `dashboard.mergify.com` would send those -//! users to a page that knows nothing about their grant. - -use std::time::Duration; -use std::time::Instant; - -use mergify_core::ApiFlavor; -use mergify_core::ApiOutcome; -use mergify_core::CliError; -use mergify_core::HttpClient; -use serde::Deserialize; -use url::Url; - -/// The `client_id` this CLI presents. The server keeps a fixed -/// allowlist and answers an unregistered id with `invalid_client`, -/// which is the anti-phishing control of the whole design: a device -/// grant open to any client lets anyone put their own words on our -/// approval page. -pub const CLIENT_ID: &str = "mergify-cli"; - -const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; - -const DEVICE_CODE_PATH: &str = "/v1/oauth/device/code"; -const TOKEN_PATH: &str = "/v1/oauth/token"; -const REVOKE_PATH: &str = "/v1/oauth/revoke"; - -/// RFC 8628 §3.5: add five seconds to the interval on every -/// `slow_down`. -const SLOW_DOWN_STEP: Duration = Duration::from_secs(5); - -/// What to say when nothing ever approved the grant. Deliberately -/// about the browser rather than about the network: the approval -/// page is where a refusal is visible, and this client cannot tell a -/// refused approval from an ignored one. -const EXPIRED_MESSAGE: &str = "the login was not approved in time. If the approval page \ - reported a problem — for example that you already hold the maximum number of Mergify \ - tokens — fix it there, then run `mergify auth login` again."; - -/// RFC 8628 §3.2 makes `interval` optional and defaults it to five -/// seconds. `expires_in` is required, but a client that fell over -/// because a deployment omitted it would be failing on the one field -/// it can pick a safe value for. -const DEFAULT_INTERVAL: Duration = Duration::from_secs(5); -const DEFAULT_EXPIRES_IN: Duration = Duration::from_secs(600); - -/// Ceilings on what the server is allowed to talk this client into. -/// `--api-url` points the CLI at an arbitrary host, and both numbers -/// come from that host: without a cap, one that answered `interval: -/// 86400` would hang `auth login` for a day and look like a bug in -/// the CLI. Both are far above anything a real deployment sends — -/// ours sends 5 and 600. -const MAX_INTERVAL: Duration = Duration::from_secs(60); -/// …and a floor, which matters more. `interval: 0` from a server -/// turns the poll loop into an unthrottled request flood for as long -/// as the grant lives — RFC 8628 §3.2 defaults the interval to five -/// seconds precisely so a client never does that. -const MIN_INTERVAL: Duration = Duration::from_secs(1); -const MAX_EXPIRES_IN: Duration = Duration::from_secs(30 * 60); -/// `expires_in` needs a floor for the same reason. It is -/// `Option`, so `expires_in: 0` is not the missing value that -/// would get [`DEFAULT_EXPIRES_IN`] — it is a deadline already -/// behind us, which makes `poll` give up after one request and blame -/// the browser for a number the server chose. -const MIN_EXPIRES_IN: Duration = Duration::from_secs(60); - -/// A pending grant, as the authorization endpoint describes it. -#[derive(Clone, Debug, Deserialize)] -pub struct Authorization { - /// The secret this client polls with. Never displayed. - pub device_code: String, - /// What the user types on the verification page. Already - /// dash-grouped by the server, so display it verbatim rather - /// than re-grouping it. - pub user_code: String, - /// Where the user goes to approve. Derived from the - /// deployment's own dashboard URL. - pub verification_uri: String, - /// The same page with the code pre-filled, when the server - /// offers it (RFC 8628 §3.2 makes it optional). - #[serde(default)] - pub verification_uri_complete: Option, - #[serde(default)] - pub expires_in: Option, - #[serde(default)] - pub interval: Option, -} - -/// The credential the token endpoint mints once a grant is approved. -#[derive(Clone, Debug, Deserialize)] -pub struct Token { - pub access_token: String, - #[serde(default)] - pub token_type: Option, - /// Seconds the token is good for. Advisory: it can be revoked - /// from the dashboard long before it runs out. - #[serde(default)] - pub expires_in: Option, - /// Declared because RFC 6749 puts it here and because the field - /// must not make deserialization fail the day it appears. The - /// Mergify API issues none today, and using one would need a - /// refresh grant that does not exist server-side either, so - /// nothing reads this yet. - #[serde(default)] - pub refresh_token: Option, -} - -/// How fast [`poll`] polls, and how long it keeps trying. -/// -/// Built from the authorization response in production. Tests build -/// one directly with millisecond values, the way `RetryPolicy` lets -/// an HTTP test skip real backoff — a `slow_down` round trip would -/// otherwise cost five real seconds. -#[derive(Copy, Clone, Debug)] -pub struct PollSchedule { - pub interval: Duration, - pub slow_down_step: Duration, - pub expires_in: Duration, -} - -impl PollSchedule { - /// The cadence the server asked for, clamped to something a - /// person will sit through. - #[must_use] - pub fn from_authorization(authorization: &Authorization) -> Self { - Self { - interval: authorization - .interval - .map_or(DEFAULT_INTERVAL, Duration::from_secs) - .clamp(MIN_INTERVAL, MAX_INTERVAL), - slow_down_step: SLOW_DOWN_STEP, - expires_in: authorization - .expires_in - .map_or(DEFAULT_EXPIRES_IN, Duration::from_secs) - .clamp(MIN_EXPIRES_IN, MAX_EXPIRES_IN), - } - } -} - -/// The RFC 6749 §5.2 error body all three endpoints answer with. -#[derive(Clone, Debug, Deserialize)] -struct OAuthError { - error: String, - #[serde(default)] - error_description: Option, -} - -impl OAuthError { - /// What to show the user. The server writes these descriptions - /// and they say more than the code does — the `access_denied` - /// you get at the token cap names the cap and the fix — so - /// prefer them, and fall back to the code when one is missing. - fn message(&self) -> String { - // Through `displayable` for the same reason the verification - // URL is: this text is written by whatever host `--api-url` - // names and printed by a trusted binary. - displayable( - &self - .error_description - .clone() - .unwrap_or_else(|| format!("the Mergify API answered {}", self.error)), - ) - } -} - -/// A client for the three device-grant endpoints. -/// -/// They are unauthenticated by design, so this carries no bearer -/// token: `logout` revokes a credential by putting it in the form -/// body, and presenting the same secret as an `Authorization` header -/// as well would be sending it twice for no reason. -pub fn client(api_url: Url) -> Result { - HttpClient::new(api_url, "", ApiFlavor::Mergify) -} - -/// Open a grant: ask the server for the code pair the user is about -/// to approve. -/// -/// `device_name` names the machine asking, so the approval page can -/// default the token's name to this one rather than to the client -/// name every machine shares. **Send the bare hostname**: the server -/// composes ` on ` itself, so a client -/// that sent "Mergify CLI on host" would produce "Mergify CLI on -/// Mergify CLI on host". It also sanitizes the value and caps the -/// composed label, which is why nothing is trimmed on this side. -/// -/// Optional on the wire: a grant without the field is what every -/// client sent until it existed, and the server keeps its own -/// default for one. It is additive against a deployment that -/// predates it, too — that endpoint reads its form fields by name -/// rather than as a strict model, so an unknown one is ignored and -/// an older on-prem still logs in. -pub async fn authorize( - client: &HttpClient, - device_name: Option<&str>, -) -> Result { - let mut form = vec![("client_id", CLIENT_ID)]; - // Omitted rather than sent empty when this machine cannot name - // itself: a grant without the field is the case every client had - // until now, and the server still has its own default for it. - if let Some(device_name) = device_name { - form.push(("device_name", device_name)); - } - match client - .post_form::(DEVICE_CODE_PATH, &form) - .await? - { - ApiOutcome::Ok(authorization) => checked(authorization), - ApiOutcome::Error { body, .. } => Err(CliError::MergifyApi(format!( - "could not start the login: {}", - body.message(), - ))), - } -} - -/// Everything the user is shown during a login comes out of the -/// authorization response, and `--api-url` — or `MERGIFY_API_URL`, -/// which a `.envrc` or a devcontainer can set without the user -/// typing anything — decides which host wrote it. The same two lines -/// above clamp `interval` and `expires_in` against that host; these -/// are the strings. -/// -/// So the verification URL has to parse and has to be `http(s)`. A -/// `javascript:` or `file:` URL printed under "Open this URL to -/// authorize the Mergify CLI" is a phishing line in this binary's -/// voice, and a lookalike host is one the user can at least see. -/// And every string that reaches the terminal is stripped of control -/// characters, because an `ESC` in `user_code` can rewrite the URL -/// already on screen. -fn checked(mut authorization: Authorization) -> Result { - authorization.user_code = displayable(&authorization.user_code); - authorization.verification_uri = checked_uri(&authorization.verification_uri)?; - authorization.verification_uri_complete = authorization - .verification_uri_complete - .as_deref() - .and_then(|raw| match checked_uri(raw) { - Ok(uri) => Some(uri), - // RFC 8628 §3.2 makes this one optional, so dropping it - // costs the user a pre-filled code while refusing would - // cost them the login. - Err(e) => { - tracing::debug!(error = %e, "ignoring an unusable verification_uri_complete"); - None - } - }); - Ok(authorization) -} - -fn checked_uri(raw: &str) -> Result { - let url = Url::parse(raw).map_err(|e| { - CliError::MergifyApi(format!( - "the deployment answered with a verification URL this client will not print \ - ({e}): {}", - displayable(raw), - )) - })?; - if !matches!(url.scheme(), "http" | "https") { - return Err(CliError::MergifyApi(format!( - "the deployment answered with a {} verification URL, and only http(s) is ever \ - printed: {}", - url.scheme(), - displayable(raw), - ))); - } - Ok(url.to_string()) -} - -/// Drop what a terminal would act on rather than print. -fn displayable(raw: &str) -> String { - raw.chars().filter(|c| !c.is_control()).collect() -} - -/// Poll the token endpoint until the grant is approved, refused, or -/// out of time. -/// -/// The three outcomes the RFC makes a client sit through — nobody -/// has answered yet, you are polling too fast, the code is gone — -/// are the ordinary path here, not failures. Anything else is -/// terminal on the first answer. -pub async fn poll( - client: &HttpClient, - device_code: &str, - schedule: PollSchedule, -) -> Result { - let form = [ - ("grant_type", GRANT_TYPE), - ("device_code", device_code), - ("client_id", CLIENT_ID), - ]; - let deadline = Instant::now() + schedule.expires_in; - let mut interval = schedule.interval; - let mut unreachable: Option = None; - - loop { - // Before the first request, not after: the user has to read - // a code and switch to a browser, so an immediate poll can - // only ever be told nobody has approved yet. - tokio::time::sleep(interval).await; - - match client - .post_form::(TOKEN_PATH, &form) - .await - { - Ok(ApiOutcome::Ok(token)) => return Ok(token), - Ok(ApiOutcome::Error { body, .. }) => { - // The endpoint answered, so an earlier transport - // failure is history: a deadline reached from here on - // is about the browser again, not about the network. - if unreachable.is_some() { - unreachable = None; - } - match body.error.as_str() { - "authorization_pending" => {} - "slow_down" => { - interval = (interval + schedule.slow_down_step).min(MAX_INTERVAL); - tracing::debug!(?interval, "the server asked us to poll more slowly"); - } - _ => return Err(CliError::MergifyApi(body.message())), - } - } - // Not a refusal. RFC 8628 §3.5 makes only `access_denied` - // and `expired_token` terminal, and a proxy's 502, a - // dropped connection or an exhausted rate limit is - // neither — while the grant the user may have approved - // one second ago is still open. Giving up here abandons a - // token the server mints and nothing ever stores or - // revokes, which is one of the twenty a user gets. So - // keep polling to the deadline, and if that is how it - // ends, report *this* rather than blaming the browser. - Err(e) => { - tracing::debug!( - error = %e, - "the token endpoint did not answer; still polling" - ); - unreachable = Some(e); - } - } - - // A backstop, not the expiry: the server drops the grant on - // its own and starts answering `expired_token`, which is the - // branch above. This one catches a deployment that would - // keep saying `authorization_pending` forever. - // - // The message points at the browser because that is where the - // answer is. An approval the server refuses — the twenty-token - // cap is refused on the approval page itself — never reaches - // this client as anything, so "nobody approved it" and "the - // page said no" are indistinguishable from here. - if Instant::now() >= deadline { - return Err( - unreachable.unwrap_or_else(|| CliError::MergifyApi(EXPIRED_MESSAGE.to_string())) - ); - } - } -} - -/// Revoke a credential (RFC 7009). -/// -/// The endpoint answers 200 for any string, including one that was -/// never a token: saying otherwise would make an unauthenticated -/// endpoint an oracle for which tokens exist. So a successful call -/// means "the server accepted the request", never "that token was -/// real" — a caller must not report the difference it cannot see. -pub async fn revoke(client: &HttpClient, token: &str) -> Result<(), CliError> { - client - .post_form_no_response(REVOKE_PATH, &[("token", token), ("client_id", CLIENT_ID)]) - .await -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use mergify_core::RetryPolicy; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_string_contains; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - /// A client with the real retry policy's shape but no wall-clock - /// backoff, so a 5xx test does not sleep. - fn test_client(server: &MockServer) -> HttpClient { - HttpClient::with_retry_policy( - Url::parse(&server.uri()).unwrap(), - "", - ApiFlavor::Mergify, - RetryPolicy { - max_attempts: 3, - initial_backoff: Duration::from_millis(0), - }, - ) - .unwrap() - } - - fn fast_schedule() -> PollSchedule { - PollSchedule { - interval: Duration::from_millis(0), - slow_down_step: Duration::from_millis(300), - expires_in: Duration::from_secs(30), - } - } - - fn oauth_error(status: u16, code: &str, description: &str) -> ResponseTemplate { - ResponseTemplate::new(status).set_body_json(serde_json::json!({ - "error": code, - "error_description": description, - })) - } - - #[tokio::test] - async fn authorize_sends_the_client_id_and_returns_both_codes() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .and(body_string_contains("client_id=mergify-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - "verification_uri_complete": - "https://dashboard.mergify.com/device?user_code=BCDF-GHJK", - "expires_in": 600, - "interval": 5, - }))) - .expect(1) - .mount(&server) - .await; - - let authorization = authorize(&test_client(&server), None).await.unwrap(); - assert_eq!(authorization.device_code, "dev-secret"); - assert_eq!(authorization.user_code, "BCDF-GHJK"); - assert_eq!( - authorization.verification_uri, - "https://dashboard.mergify.com/device", - ); - let schedule = PollSchedule::from_authorization(&authorization); - assert_eq!(schedule.interval, Duration::from_secs(5)); - assert_eq!(schedule.expires_in, Duration::from_secs(600)); - } - - // The machine's own name, so the approval page can default the - // token to "Mergify CLI on work-laptop" instead of the client - // name every machine shares. - #[tokio::test] - async fn authorize_sends_the_device_name() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .and(body_string_contains("client_id=mergify-cli")) - .and(body_string_contains("device_name=work-laptop")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - }))) - .expect(1) - .mount(&server) - .await; - - authorize(&test_client(&server), Some("work-laptop")) - .await - .unwrap(); - } - - // A machine that cannot name itself sends no field at all. An - // empty one is not the same thing: the server would sanitize it - // back to nothing, and a deployment that predates the field has - // to see exactly the request it has always seen. - #[tokio::test] - async fn authorize_omits_the_device_name_when_there_is_none() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - }))) - .expect(1) - .mount(&server) - .await; - - authorize(&test_client(&server), None).await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body = String::from_utf8_lossy(&requests[0].body); - assert_eq!(body, "client_id=mergify-cli", "got {body:?}"); - } - - // A deployment that omits the two optional numbers still has to - // produce a usable schedule, at the RFC's defaults. - #[tokio::test] - async fn authorize_tolerates_a_response_without_interval_or_expiry() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - }))) - .expect(1) - .mount(&server) - .await; - - let authorization = authorize(&test_client(&server), None).await.unwrap(); - assert_eq!(authorization.verification_uri_complete, None); - let schedule = PollSchedule::from_authorization(&authorization); - assert_eq!(schedule.interval, DEFAULT_INTERVAL); - assert_eq!(schedule.expires_in, DEFAULT_EXPIRES_IN); - } - - // `--api-url` points at whatever host the caller names, and both - // numbers come from that host. - #[test] - fn a_server_cannot_talk_the_client_into_waiting_forever() { - let authorization = Authorization { - device_code: "d".to_string(), - user_code: "u".to_string(), - verification_uri: "https://example.test/device".to_string(), - verification_uri_complete: None, - expires_in: Some(86_400), - interval: Some(86_400), - }; - // Literal bounds, not the constants that do the clamping: - // asserting `== MAX_INTERVAL` passes for any value of - // `MAX_INTERVAL`, including one that reintroduces the bug. - let schedule = PollSchedule::from_authorization(&authorization); - assert_eq!(schedule.interval, Duration::from_secs(60)); - assert_eq!(schedule.expires_in, Duration::from_secs(30 * 60)); - } - - // The other end of the same threat, and the worse one: a zero - // interval is not a slow client, it is an unthrottled flood - // aimed at whatever host `--api-url` names. - #[test] - fn a_server_cannot_talk_the_client_into_polling_flat_out() { - let authorization = Authorization { - device_code: "d".to_string(), - user_code: "u".to_string(), - verification_uri: "https://example.test/device".to_string(), - verification_uri_complete: None, - expires_in: Some(600), - interval: Some(0), - }; - assert_eq!( - PollSchedule::from_authorization(&authorization).interval, - Duration::from_secs(1), - ); - } - - // And the same threat aimed the other way: `expires_in: 0` is a - // deadline already behind us, so `poll` would make one request - // and tell the user their browser was too slow. - #[test] - fn a_server_cannot_talk_the_client_into_giving_up_instantly() { - let authorization = Authorization { - device_code: "d".to_string(), - user_code: "u".to_string(), - verification_uri: "https://example.test/device".to_string(), - verification_uri_complete: None, - expires_in: Some(0), - interval: Some(5), - }; - assert_eq!( - PollSchedule::from_authorization(&authorization).expires_in, - Duration::from_secs(60), - ); - } - - #[tokio::test] - async fn a_verification_url_that_is_not_http_is_refused() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "javascript:fetch('https://attacker.test')", - "expires_in": 600, - "interval": 5, - }))) - .expect(1) - .mount(&server) - .await; - - let err = authorize(&test_client(&server), None).await.unwrap_err(); - assert!(err.to_string().contains("only http(s)"), "got {err}"); - } - - // A server-written string reaches the terminal inside a line this - // binary signs its own name to. An `ESC` in it can rewrite the - // URL printed above. - #[tokio::test] - async fn a_hostile_code_cannot_rewrite_what_is_already_on_screen() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF\r\u{1b}[2Kgo to https://attacker.test", - "verification_uri": "https://dashboard.mergify.com/device", - "verification_uri_complete": "not a url at all", - "expires_in": 600, - "interval": 5, - }))) - .expect(1) - .mount(&server) - .await; - - let authorization = authorize(&test_client(&server), None).await.unwrap(); - assert_eq!( - authorization.user_code, - "BCDF[2Kgo to https://attacker.test", - ); - // The optional pre-filled URL is dropped rather than fatal: - // the login still works off `verification_uri`. - assert_eq!(authorization.verification_uri_complete, None); - } - - // RFC 8628 §3.5 makes only `access_denied` and `expired_token` - // terminal. A 502 from a proxy is neither, and the grant the user - // may already have approved is still open — abandoning it leaks a - // token that is minted, never stored and never revoked. - #[tokio::test] - async fn poll_survives_an_endpoint_that_stops_answering() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(502).set_body_string("bad gateway")) - .up_to_n_times(3) - .expect(3) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "mut_secret", - }))) - .expect(1) - .mount(&server) - .await; - - let token = poll(&test_client(&server), "dev-secret", fast_schedule()) - .await - .unwrap(); - assert_eq!(token.access_token, "mut_secret"); - } - - // ...and when that is how the whole window ends, the error says - // the endpoint never answered rather than blaming the browser. - #[tokio::test] - async fn poll_reports_the_transport_failure_it_died_on() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(502).set_body_string("bad gateway")) - .mount(&server) - .await; - - let schedule = PollSchedule { - interval: Duration::from_millis(0), - slow_down_step: Duration::from_millis(0), - expires_in: Duration::from_millis(0), - }; - let err = poll(&test_client(&server), "dev-secret", schedule) - .await - .unwrap_err(); - assert!(err.to_string().contains("502"), "got {err}"); - assert!( - !err.to_string().contains("not approved in time"), - "the browser is not what failed here: got {err}", - ); - } - - #[tokio::test] - async fn authorize_surfaces_an_unregistered_client_id() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(oauth_error(401, "invalid_client", "Unknown client_id.")) - .expect(1) - .mount(&server) - .await; - - let err = authorize(&test_client(&server), None).await.unwrap_err(); - assert!(err.to_string().contains("Unknown client_id."), "got {err}"); - assert_eq!(err.exit_code(), mergify_core::ExitCode::MergifyApiError); - } - - #[tokio::test] - async fn poll_waits_out_authorization_pending() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .and(body_string_contains("device_code=dev-secret")) - .respond_with(oauth_error( - 400, - "authorization_pending", - "Waiting for the user to approve.", - )) - .up_to_n_times(2) - .expect(2) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "mut_secret", - "token_type": "bearer", - "expires_in": 31_536_000, - }))) - .expect(1) - .mount(&server) - .await; - - let token = poll(&test_client(&server), "dev-secret", fast_schedule()) - .await - .unwrap(); - assert_eq!(token.access_token, "mut_secret"); - assert_eq!(token.expires_in, Some(31_536_000)); - assert_eq!(token.refresh_token, None); - } - - // RFC 8628 §3.5: `slow_down` means "add five seconds", not "give - // up". Measured rather than asserted on a field, because the - // interval only exists as a sleep. - #[tokio::test] - async fn poll_backs_off_when_told_to_slow_down() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(oauth_error(400, "slow_down", "Polling too fast.")) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "mut_secret", - }))) - .expect(1) - .mount(&server) - .await; - - let started = Instant::now(); - let token = poll(&test_client(&server), "dev-secret", fast_schedule()) - .await - .unwrap(); - assert_eq!(token.access_token, "mut_secret"); - assert!( - started.elapsed() >= Duration::from_millis(250), - "the second poll must wait one slow_down step longer, waited {:?}", - started.elapsed(), - ); - } - - // A refusal comes back as a sentence the server wrote, and it - // says more than the error code does. Showing `access_denied` - // instead would drop the only part the user can act on. - #[tokio::test] - async fn poll_surfaces_the_server_description_of_a_refusal() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(oauth_error( - 400, - "access_denied", - "You already hold the maximum number of Mergify tokens. \ - Revoke one from the dashboard and try again.", - )) - .expect(1) - .mount(&server) - .await; - - let err = poll(&test_client(&server), "dev-secret", fast_schedule()) - .await - .unwrap_err(); - assert!( - err.to_string().contains("Revoke one from the dashboard"), - "got {err}", - ); - } - - #[tokio::test] - async fn poll_stops_on_an_expired_code() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(oauth_error( - 400, - "expired_token", - "Unknown or expired device_code.", - )) - .expect(1) - .mount(&server) - .await; - - let err = poll(&test_client(&server), "dev-secret", fast_schedule()) - .await - .unwrap_err(); - assert!(err.to_string().contains("expired"), "got {err}"); - } - - // The backstop: a deployment that answers `authorization_pending` - // forever must not hang the command forever. - #[tokio::test] - async fn poll_gives_up_at_its_own_deadline() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(oauth_error( - 400, - "authorization_pending", - "Waiting for the user to approve.", - )) - .mount(&server) - .await; - - let schedule = PollSchedule { - interval: Duration::from_millis(0), - slow_down_step: Duration::from_millis(0), - expires_in: Duration::from_millis(0), - }; - let err = poll(&test_client(&server), "dev-secret", schedule) - .await - .unwrap_err(); - assert!( - err.to_string().contains("not approved in time"), - "got {err}" - ); - assert!( - err.to_string().contains("approval page"), - "the timeout has to point at the browser, where a refusal is visible: got {err}", - ); - } - - #[tokio::test] - async fn revoke_posts_the_token_and_the_client_id() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .and(body_string_contains("token=mut_secret")) - .and(body_string_contains("client_id=mergify-cli")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - revoke(&test_client(&server), "mut_secret").await.unwrap(); - } - - // The device-grant client never presents a bearer token: these - // endpoints are unauthenticated, and `revoke` already carries the - // secret in the form body. - #[tokio::test] - async fn the_client_sends_no_authorization_header() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let client = client(Url::parse(&server.uri()).unwrap()).unwrap(); - revoke(&client, "mut_secret").await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert!( - requests[0].headers.get("authorization").is_none(), - "got {:?}", - requests[0].headers, - ); - } -} diff --git a/crates/mergify-auth/src/identity.rs b/crates/mergify-auth/src/identity.rs deleted file mode 100644 index b0551ffc..00000000 --- a/crates/mergify-auth/src/identity.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! `GET /v1/user` — which account a credential belongs to. -//! -//! The device grant's token response says nothing about who -//! approved it, and every other `user`-authenticated route on the -//! API takes an `{owner}` or `{owner}/{repository}` path parameter, -//! so there is no other way to turn a credential into a name. -//! -//! The three answers are three different sentences to the user, and -//! reporting any of them as another would be a lie: -//! -//! - **200** — the credential is live, and this is whose it is. -//! - **403** — refused. Revoked from the dashboard, expired, or not -//! a user credential at all (an application key lands here). -//! **401** counts as the same answer: it is what RFC 6750 says a -//! rejected bearer token gets, it is what this API's own device -//! endpoints answer for a bad client, and any gateway in front of -//! a self-hosted install can produce one on its own. Reporting it -//! as an unexpected status would replace "run `mergify auth -//! login`" with a sentence naming no remedy. -//! - **404** — the deployment predates the route. Self-hosted -//! installs upgrade on their own schedule, so this is a normal -//! answer, not a broken one. - -use mergify_core::ApiFlavor; -use mergify_core::ApiOutcome; -use mergify_core::CliError; -use mergify_core::HttpClient; -use serde::Deserialize; -use url::Url; - -const USER_PATH: &str = "/v1/user"; - -/// The account a credential belongs to. -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -pub struct User { - pub id: u64, - pub login: String, -} - -/// What `GET /v1/user` had to say. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Identity { - Known(User), - /// The API refused the credential. - Refused, - /// This deployment does not serve the route. - Unsupported, -} - -/// A rejection body. Every refusal from the API app carries -/// `FastAPI`'s `detail`, and an unexpected status is the one case -/// where the server's own sentence is all the caller has to go on. -#[derive(Deserialize)] -struct Rejection { - #[serde(default)] - detail: Option, -} - -/// Ask the API who `token` belongs to. -pub async fn whoami(api_url: Url, token: &str) -> Result { - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - match client.get_outcome::(USER_PATH).await? { - ApiOutcome::Ok(user) => Ok(Identity::Known(user)), - ApiOutcome::Error { - status: 401 | 403, .. - } => Ok(Identity::Refused), - ApiOutcome::Error { status: 404, .. } => Ok(Identity::Unsupported), - ApiOutcome::Error { status, body } => Err(CliError::MergifyApi(match body.detail { - Some(detail) => format!("unexpected HTTP {status} from {USER_PATH}: {detail}"), - None => format!("unexpected HTTP {status} from {USER_PATH}"), - })), - } -} - -/// The login name behind `token`, or `None` for every reason there -/// might not be one — including a failure to ask. -/// -/// For callers that only want to put a name in a sentence and have -/// nothing to do with the difference: `auth login` has just minted -/// the credential, so a network hiccup on the way to `/v1/user` -/// must not turn a successful login into a failed command. -pub async fn login_name(api_url: Url, token: &str) -> Option { - match whoami(api_url, token).await { - Ok(Identity::Known(user)) => Some(user.login), - Ok(other) => { - tracing::debug!(?other, "the API did not name the credential's owner"); - None - } - Err(e) => { - tracing::debug!(error = %e, "could not ask the API who the credential belongs to"); - None - } - } -} - -#[cfg(test)] -mod tests { - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - fn url(server: &MockServer) -> Url { - Url::parse(&server.uri()).unwrap() - } - - async fn mount(server: &MockServer, response: ResponseTemplate) { - Mock::given(method("GET")) - .and(path("/v1/user")) - .and(header("Authorization", "Bearer mut_secret")) - .respond_with(response) - .expect(1) - .mount(server) - .await; - } - - #[tokio::test] - async fn a_live_credential_names_its_owner() { - let server = MockServer::start().await; - mount( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": 42, "login": "sileht"})), - ) - .await; - - assert_eq!( - whoami(url(&server), "mut_secret").await.unwrap(), - Identity::Known(User { - id: 42, - login: "sileht".to_string(), - }), - ); - } - - // 403 is what every `/v1` route answers for a refused - // credential; 401 is below. - #[tokio::test] - async fn a_refused_credential_is_not_an_error() { - let server = MockServer::start().await; - mount( - &server, - ResponseTemplate::new(403).set_body_json(serde_json::json!({"detail": "forbidden"})), - ) - .await; - - assert_eq!( - whoami(url(&server), "mut_secret").await.unwrap(), - Identity::Refused, - ); - } - - // Not what the Mergify API itself answers, but what a gateway in - // front of a self-hosted install can. The user's move is the - // same either way, so the answer has to be too. - #[tokio::test] - async fn a_401_is_a_refusal_too() { - let server = MockServer::start().await; - mount( - &server, - ResponseTemplate::new(401).set_body_json(serde_json::json!({"detail": "expired"})), - ) - .await; - - assert_eq!( - whoami(url(&server), "mut_secret").await.unwrap(), - Identity::Refused, - ); - } - - // A self-hosted install older than the route. Normal, not broken. - #[tokio::test] - async fn a_deployment_without_the_route_is_not_an_error() { - let server = MockServer::start().await; - mount( - &server, - ResponseTemplate::new(404).set_body_json(serde_json::json!({"detail": "Not Found"})), - ) - .await; - - assert_eq!( - whoami(url(&server), "mut_secret").await.unwrap(), - Identity::Unsupported, - ); - } - - #[tokio::test] - async fn login_name_swallows_a_refusal() { - let server = MockServer::start().await; - mount( - &server, - ResponseTemplate::new(403).set_body_json(serde_json::json!({"detail": "forbidden"})), - ) - .await; - - assert_eq!(login_name(url(&server), "mut_secret").await, None); - } - - // The arm that justifies `login` calling this at all: a request - // that fails outright must not turn a login the server already - // granted into a failed command. A 200 whose body is not a user - // reaches that arm without the three retries a 5xx would cost. - #[tokio::test] - async fn login_name_swallows_a_failure_to_ask() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"unexpected": true})), - ) - .mount(&server) - .await; - - assert_eq!(login_name(url(&server), "mut_secret").await, None); - // …and the typed call still surfaces it, so the swallowing is - // `login_name`'s decision rather than a hidden one. - assert!(whoami(url(&server), "mut_secret").await.is_err()); - } -} diff --git a/crates/mergify-auth/src/lib.rs b/crates/mergify-auth/src/lib.rs deleted file mode 100644 index 863f7186..00000000 --- a/crates/mergify-auth/src/lib.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! `mergify auth` — the Mergify-issued, per-user credential. -//! -//! - [`browser`] — putting the approval page in front of the user, -//! which is a convenience the login never depends on. -//! - [`device`] — the OAuth 2.0 device authorization grant -//! (RFC 8628) against the Mergify API, which is how the CLI gets -//! a credential without ever handling a password or a GitHub -//! token. -//! - [`identity`] — `GET /v1/user`, the only way to turn a -//! credential into an account name and the only way to tell a -//! live one from a revoked one. -//! - [`machine`] — the hostname a login sends so the approval page -//! can name the machine asking. -//! - [`login`] / [`logout`] / [`status`] — the three commands. -//! -//! The credential itself is stored by -//! [`mergify_core::CredentialStore`]; this crate obtains it, -//! revokes it, and reports on it. - -pub mod browser; -pub mod device; -pub mod identity; -pub mod login; -pub mod logout; -pub mod machine; -pub mod status; - -#[cfg(test)] -mod testing { - use mergify_core::CredentialStore; - - /// A credential store that cannot reach the developer's real - /// keychain, rooted in a temporary directory the caller keeps - /// alive for the length of the test. - pub fn file_store() -> (tempfile::TempDir, CredentialStore) { - let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::file_at(dir.path().join("credentials.json")); - (dir, store) - } - - /// Run `body` to completion with `MERGIFY_TOKEN` forced to - /// `value`. - /// - /// The overlay is installed on this thread and the future is - /// driven on it, by a `current_thread` runtime built here. - /// Without this the wiring that reads the variable is - /// untestable, and untestable wiring is wiring a future edit can - /// delete with the suite still green: asserting on the renderer - /// alone proves only that the renderer can print a note, never - /// that anything asks it to. - pub fn with_mergify_token(value: Option<&str>, body: F) -> F::Output { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - mergify_core::env::testing::with_var("MERGIFY_TOKEN", value, || runtime.block_on(body)) - } -} diff --git a/crates/mergify-auth/src/login.rs b/crates/mergify-auth/src/login.rs deleted file mode 100644 index 36e93f1c..00000000 --- a/crates/mergify-auth/src/login.rs +++ /dev/null @@ -1,961 +0,0 @@ -//! `mergify auth login` — obtain a Mergify credential and store it. - -use std::io::Write; - -use chrono::DateTime; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::Credential; -use mergify_core::CredentialStore; -use mergify_core::Output; -use mergify_core::auth; -use mergify_core::credentials::Location; -use serde::Serialize; -use url::Url; - -use crate::browser::Browser; -use crate::device; -use crate::identity; -use crate::machine; - -pub struct LoginOptions<'a> { - pub api_url: Option<&'a str>, - pub store: &'a CredentialStore, - /// Where to open the approval page, or `None` for - /// `--no-browser`. Opening it is a convenience: the URL is - /// printed either way, and a browser that will not open does - /// not fail the login. - pub browser: Option<&'a dyn Browser>, - /// `--device-name`: what to call this machine on the approval - /// page instead of its hostname. `None` detects the hostname, - /// and a blank name sends none at all, for a user who does not - /// want the hostname to leave the machine: the grant request is - /// unauthenticated and the server holds the value before anyone - /// approves, so editing the field on the page is too late. - pub device_name: Option<&'a str>, -} - -/// What `auth login` produced, for the JSON rendering `Output` -/// requires. Deliberately without the token: nothing prints the -/// secret, ever. -#[derive(Serialize)] -struct LoginResult { - api_url: String, - login: Option, - stored_in: String, - /// The environment variable that will be used *instead of* the - /// credential just stored, if one is set. - overridden_by: Option, -} - -/// Run the `auth login` command. -pub async fn run(opts: LoginOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let api_url = auth::resolve_api_url(opts.api_url)?; - let client = device::client(api_url.clone())?; - - // Before any grant exists, not merely before it is spent. A - // store this machine cannot read is a corrupt file or a keychain - // holding non-JSON, and reading it after the code is on screen - // means the user is already at the approval page when the - // command dies — and whatever they approve there mints a token - // nothing stores and nothing revokes. `authorize` needs no - // store, so this costs nothing. - let previous = opts.store.get(&api_url)?; - - // The machine's own name, so the approval page's Token name - // field defaults to this machine instead of to the client name - // every machine shares — the page's own helper text already asks - // the user to name it after the machine. Optional: a machine - // that cannot name itself, or a deployment that predates the - // field, falls back to that shared default. `--device-name` - // replaces the detected name, and a blank one is the opt-out: - // the same request a machine that cannot name itself makes. - let device_name = match opts.device_name { - Some(name) => (!name.trim().is_empty()).then(|| name.to_string()), - None => machine::name().await, - }; - let authorization = device::authorize(&client, device_name.as_deref()).await?; - let opened = open_verification_page(opts.browser, verification_url(&authorization)); - output.status(&instructions(&authorization, opened))?; - - let token = device::poll( - &client, - &authorization.device_code, - device::PollSchedule::from_authorization(&authorization), - ) - .await?; - - let credential = Credential { - expires_at: token - .expires_in - .and_then(|seconds| expires_at(Utc::now(), seconds)), - token: token.access_token, - }; - let location = match opts.store.set(&api_url, &credential) { - Ok(location) => location, - Err(e) => { - // The server minted a token this machine cannot keep. - // Leaving it live would burn one of the user's twenty - // for nothing, and every retry would burn another. - revoke_quietly(&client, &credential.token).await; - return Err(e); - } - }; - - // The credential this one replaces is now unreachable from here - // and still live on the server for its full year. A user who - // logs in again on the same machine — after a token stopped - // working, or on a reprovisioned box — would otherwise leak one - // per login until they hit the cap and only the dashboard could - // clear it. - if let Some(previous) = &previous - && let Err(e) = device::revoke(&client, &previous.credential.token).await - { - // Out loud, not a debug line. This is the leak the block - // above exists to prevent, and the user is the only one who - // can finish it: the old token is live for its full year and - // no longer reachable from this machine. `logout` says the - // same thing when its revocation fails. - output.status(&format!( - "Warning: the credential this login replaces could not be revoked ({e}). Revoke \ - it from the CLI Tokens page of your Mergify dashboard — it stays valid, and \ - counts against your token limit, until you do.", - ))?; - } - // After the credential is stored, never before: a name is a nice - // sentence, and losing the token the server already minted - // because the network blinked on the way to `/v1/user` is not a - // trade worth making. - let login = identity::login_name(api_url.clone(), &credential.token).await; - - // `login` is the moment the user is actually watching. Telling - // them here that something in their shell outranks what they - // just approved is the difference between a puzzling 403 an hour - // later and one line now — and `auth status`, which says the - // same thing, is a command they have no reason to run after a - // login that appeared to succeed. - // - // With one correction: when the variable holds the credential - // this login just replaced and revoked, "commands use it - // instead" is worse than no note at all. Re-logging in with the - // token exported is the obvious way to reach this. - let overriding = auth::overriding_env_var(); - let overriding = match &previous { - Some(previous) if auth::overriding_env_var_holds(&previous.credential.token) => { - output.status( - "Warning: MERGIFY_TOKEN holds the credential this login replaced, which has \ - been revoked. Unset it so Mergify commands use the new one.", - )?; - None - } - _ => overriding, - }; - - emit(output, &api_url, login.as_deref(), &location, overriding) -} - -/// Ask the server to revoke a token this machine could not store, -/// and say nothing if it will not. -/// -/// The command is already returning its own error, and the user -/// never asked about this token: it was minted a moment ago and -/// never reached the store, so there is nothing they can do about it -/// that the error above does not already lead to. -async fn revoke_quietly(client: &mergify_core::HttpClient, token: &str) { - if let Err(e) = device::revoke(client, token).await { - tracing::debug!(error = %e, "could not revoke the credential we could not store"); - } -} - -/// The page the user approves on: the one with the code already -/// filled in when the server offers it (RFC 8628 §3.3.1), the plain -/// one otherwise. Both the browser and the printed instructions go -/// through here, so what opens and what is on screen cannot drift -/// apart. -fn verification_url(authorization: &device::Authorization) -> &str { - authorization - .verification_uri_complete - .as_deref() - .unwrap_or(&authorization.verification_uri) -} - -/// Try to put the approval page in front of the user, and report -/// whether it worked so the instructions can say something true. -/// -/// Returns rather than fails, always. A CI runner, a container and -/// an SSH session have no browser to open, and none of that is a -/// reason to refuse a login whose whole design is that the approval -/// happens somewhere else. -fn open_verification_page(browser: Option<&dyn Browser>, url: &str) -> bool { - let Some(browser) = browser else { return false }; - match browser.open(url) { - Ok(()) => true, - Err(e) => { - tracing::debug!(error = %e, "could not open the verification page"); - false - } - } -} - -/// What the user has to do, on stderr, while the poll loop waits. -/// -/// The URL is printed whether or not a browser took it: a spawned -/// opener is not a window on screen, and the terminal is the only -/// place the user can be sure to find the address again. -fn instructions(authorization: &device::Authorization, opened: bool) -> String { - let theme = mergify_tui::Theme::detect(); - let url = verification_url(authorization); - let open = if opened { - "Opening your browser to authorize the Mergify CLI. If nothing opens, go to:" - } else { - "Open this URL to authorize the Mergify CLI:" - }; - format!( - "{open}\n\n {url}\n\n\ - and confirm this code:\n\n {bold}{code}{reset}\n\n\ - Waiting for approval…", - bold = theme.bold.render(), - code = authorization.user_code, - reset = theme.reset, - ) -} - -/// The absolute moment a token minted `now` runs out. `None` when -/// the server's `expires_in` does not fit a timestamp, which is a -/// server that has stopped making sense rather than a reason to -/// throw the credential away. -fn expires_at(now: DateTime, expires_in_seconds: u64) -> Option> { - now.checked_add_signed(chrono::TimeDelta::try_seconds( - i64::try_from(expires_in_seconds).ok()?, - )?) -} - -fn emit( - output: &mut dyn Output, - api_url: &Url, - login: Option<&str>, - location: &Location, - overriding: Option<&'static str>, -) -> Result<(), CliError> { - let result = LoginResult { - api_url: api_url.to_string(), - login: login.map(str::to_owned), - stored_in: location.to_string(), - overridden_by: overriding.map(str::to_owned), - }; - let theme = mergify_tui::Theme::detect(); - output.emit(&result, &mut |w: &mut dyn Write| { - let who = match login { - Some(login) => format!(" as {login}"), - None => String::new(), - }; - writeln!( - w, - "{green}✓{reset} Logged in to {api_url}{who}.", - green = theme.green.render(), - reset = theme.reset, - )?; - writeln!(w, "Credential stored in {location}.")?; - if let Some(name) = overriding { - writeln!( - w, - "\n{warn}Note:{reset} {name} is set, so Mergify commands use it instead of \ - the credential you just stored. Unset it to use this login.", - warn = theme.warn.render(), - reset = theme.reset, - )?; - } - Ok(()) - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_string_contains; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - use crate::testing::file_store; - use crate::testing::with_mergify_token; - - fn authorization() -> device::Authorization { - device::Authorization { - device_code: "dev-secret".to_string(), - user_code: "BCDF-GHJK".to_string(), - verification_uri: "https://dashboard.mergify.com/device".to_string(), - verification_uri_complete: Some( - "https://dashboard.mergify.com/device?user_code=BCDF-GHJK".to_string(), - ), - expires_in: Some(600), - interval: Some(0), - } - } - - /// A browser that records instead of opening one, so the suite - /// never puts a window on the screen of whoever ran it. - struct RecordingBrowser { - opened: std::sync::Mutex>, - works: bool, - } - - impl RecordingBrowser { - fn working() -> Self { - Self { - opened: std::sync::Mutex::new(Vec::new()), - works: true, - } - } - - fn broken() -> Self { - Self { - opened: std::sync::Mutex::new(Vec::new()), - works: false, - } - } - - fn opened(&self) -> Vec { - self.opened.lock().unwrap().clone() - } - } - - impl crate::browser::Browser for RecordingBrowser { - fn open(&self, url: &str) -> std::io::Result<()> { - self.opened.lock().unwrap().push(url.to_string()); - if self.works { - Ok(()) - } else { - Err(std::io::Error::other("no browser here")) - } - } - } - - async fn mount_flow(server: &MockServer, with_identity: bool) { - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - "verification_uri_complete": - "https://dashboard.mergify.com/device?user_code=BCDF-GHJK", - "expires_in": 600, - "interval": 0, - }))) - .expect(1) - .mount(server) - .await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "access_token": "mut_secret", - "token_type": "bearer", - "expires_in": 31_536_000, - }))) - .expect(1) - .mount(server) - .await; - let user = if with_identity { - ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": 42, - "login": "sileht", - })) - } else { - ResponseTemplate::new(404).set_body_json(serde_json::json!({"detail": "Not Found"})) - }; - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with(user) - .expect(1) - .mount(server) - .await; - } - - #[test] - fn login_stores_the_credential_and_names_the_account() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let api_url = Url::parse(&server.uri()).unwrap(); - let stored = store.get(&api_url).unwrap().unwrap(); - assert_eq!(stored.credential.token, "mut_secret"); - assert!( - stored.credential.expires_at.is_some(), - "the server's expires_in must become a stored expiry", - ); - - let stdout = captured.stdout(); - assert!(stdout.contains("Logged in to"), "got {stdout:?}"); - assert!(stdout.contains("as sileht."), "got {stdout:?}"); - assert!( - !stdout.contains("mut_secret"), - "the token must never be printed, got {stdout:?}", - ); - drop(dir); - }); - } - - // The verification URL and the code are the whole point of the - // command, and they go to stderr so stdout stays the result. - #[test] - fn the_code_and_url_are_printed_to_stderr() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stderr = captured.stderr(); - assert!(stderr.contains("BCDF-GHJK"), "got {stderr:?}"); - assert!( - stderr.contains("https://dashboard.mergify.com/device?user_code=BCDF-GHJK"), - "got {stderr:?}", - ); - drop(dir); - }); - } - - // The note has to reach the user from `run`, not merely be - // printable by the renderer: a `MERGIFY_TOKEN` in the shell - // makes every command ignore the credential this one just - // stored, and `login` is the last moment anybody is watching. - #[test] - fn login_reports_an_overriding_env_var() { - let (dir, store) = file_store(); - let mut captured = Captured::human(); - let stdout = with_mergify_token(Some("env-token"), async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - captured.stdout() - }); - - assert!(stdout.contains("Logged in to"), "got {stdout:?}"); - assert!( - stdout.contains("MERGIFY_TOKEN is set, so Mergify commands use it"), - "got {stdout:?}", - ); - drop(dir); - } - - // A deployment too old to serve `/v1/user` still logs in; it just - // cannot say whose credential it minted. - #[test] - fn login_succeeds_when_the_deployment_cannot_name_the_account() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, false).await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let api_url = Url::parse(&server.uri()).unwrap(); - assert!(store.get(&api_url).unwrap().is_some()); - let stdout = captured.stdout(); - assert!(stdout.contains("Logged in to"), "got {stdout:?}"); - assert!(!stdout.contains(" as "), "got {stdout:?}"); - drop(dir); - }); - } - - // The credential a re-login replaces is unreachable from this - // machine afterwards and still live on the server for its full - // year. Leaking one per login walks the user into the token cap, - // which only the dashboard can clear. - #[test] - fn login_revokes_the_credential_it_replaces() { - let (dir, store) = file_store(); - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .and(body_string_contains("token=mut_previous")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - let api_url = Url::parse(&server.uri()).unwrap(); - store - .set( - &api_url, - &Credential { - token: "mut_previous".to_string(), - expires_at: None, - }, - ) - .unwrap(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - assert_eq!( - store.get(&api_url).unwrap().unwrap().credential.token, - "mut_secret", - ); - }); - drop(dir); - } - - // A refusal from the server must leave nothing behind. - // The store is read before any grant exists, so an unreadable - // one fails before the user has a code in front of them. Read - // after, the command dies with the approval page already open, - // and whatever they approve there mints a token nothing stores - // and nothing can revoke. - #[test] - fn an_unreadable_store_fails_before_a_grant_is_opened() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("credentials.json"); - std::fs::write(&file, b"{ this is not json").unwrap(); - let store = mergify_core::CredentialStore::file_at(file); - with_mergify_token(None, async { - let server = MockServer::start().await; - let mut captured = Captured::human(); - - let err = run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - - assert!(err.to_string().contains("credentials.json"), "got {err}"); - assert!( - server.received_requests().await.unwrap().is_empty(), - "no grant may be opened before the store is known to be readable", - ); - assert_eq!(captured.stderr(), ""); - }); - drop(dir); - } - - // A revocation that fails leaves the replaced token live for its - // full year, reachable only from the dashboard. Saying so is the - // whole point of revoking it here. - #[test] - fn a_failed_revocation_of_the_replaced_credential_is_said_out_loud() { - let (dir, store) = file_store(); - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(429).append_header("retry-after", "0")) - .mount(&server) - .await; - let api_url = Url::parse(&server.uri()).unwrap(); - store - .set( - &api_url, - &Credential { - token: "mut_previous".to_string(), - expires_at: None, - }, - ) - .unwrap(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - // The login itself still succeeded. - assert_eq!( - store.get(&api_url).unwrap().unwrap().credential.token, - "mut_secret", - ); - let stderr = captured.stderr(); - assert!(stderr.contains("could not be revoked"), "got {stderr:?}"); - assert!(stderr.contains("CLI Tokens"), "got {stderr:?}"); - }); - drop(dir); - } - - // Re-logging in with the old credential exported: the variable - // now holds a revoked token, so "commands use it instead" would - // send the user off with a dead one. - #[test] - fn login_says_to_unset_an_env_var_holding_the_replaced_credential() { - let (dir, store) = file_store(); - with_mergify_token(Some("mut_previous"), async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - let api_url = Url::parse(&server.uri()).unwrap(); - store - .set( - &api_url, - &Credential { - token: "mut_previous".to_string(), - expires_at: None, - }, - ) - .unwrap(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stderr = captured.stderr(); - assert!( - stderr.contains("holds the credential this login replaced"), - "got {stderr:?}", - ); - let stdout = captured.stdout(); - assert!(!stdout.contains("Note:"), "got {stdout:?}"); - }); - drop(dir); - } - - #[test] - fn a_denied_grant_stores_nothing() { - with_mergify_token(None, async { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/device/code")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "device_code": "dev-secret", - "user_code": "BCDF-GHJK", - "verification_uri": "https://dashboard.mergify.com/device", - "expires_in": 600, - "interval": 0, - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/oauth/token")) - .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ - "error": "access_denied", - "error_description": "The request was denied by the user.", - }))) - .mount(&server) - .await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - let err = run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - - assert!(err.to_string().contains("denied by the user"), "got {err}"); - let api_url = Url::parse(&server.uri()).unwrap(); - assert_eq!(store.get(&api_url).unwrap(), None); - drop(dir); - }); - } - - // Without `verification_uri_complete` the user has to type the - // code, so the plain page is the one to point them at. - #[test] - fn the_instructions_fall_back_to_the_plain_verification_url() { - let mut authorization = authorization(); - authorization.verification_uri_complete = None; - let rendered = instructions(&authorization, false); - assert!( - rendered.contains("https://dashboard.mergify.com/device\n"), - "got {rendered:?}", - ); - assert!(rendered.contains("BCDF-GHJK"), "got {rendered:?}"); - } - - // The same page the user is told to open is the one that opens: - // the pre-filled one, so the code they are comparing is already - // in the field. - #[test] - fn login_opens_the_verification_page() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let browser = RecordingBrowser::working(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: Some(&browser), - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - assert_eq!( - browser.opened(), - ["https://dashboard.mergify.com/device?user_code=BCDF-GHJK"], - ); - let stderr = captured.stderr(); - assert!(stderr.contains("Opening your browser"), "got {stderr:?}"); - // Still printed, always: a spawned opener is not a - // window on screen, and this is where the user looks. - assert!( - stderr.contains("https://dashboard.mergify.com/device?user_code=BCDF-GHJK"), - "got {stderr:?}", - ); - drop(dir); - }); - } - - // Opening is a convenience, and the machines that cannot do it - // — a CI runner, a container, an SSH session — are the ones the - // device grant exists for. - #[test] - fn a_browser_that_will_not_open_does_not_fail_the_login() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let browser = RecordingBrowser::broken(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: Some(&browser), - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let api_url = Url::parse(&server.uri()).unwrap(); - assert_eq!( - store.get(&api_url).unwrap().unwrap().credential.token, - "mut_secret", - ); - let stderr = captured.stderr(); - assert!( - stderr.contains("Open this URL to authorize"), - "a browser that did not open must not be claimed to have opened, got {stderr:?}", - ); - assert!( - stderr.contains("https://dashboard.mergify.com/device?user_code=BCDF-GHJK"), - "got {stderr:?}", - ); - drop(dir); - }); - } - - /// The form body `run` sent on the grant request. - async fn grant_body(device_name: Option<&str>) -> String { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let grant = requests - .iter() - .find(|r| r.url.path() == "/v1/oauth/device/code") - .expect("the grant request"); - drop(dir); - String::from_utf8_lossy(&grant.body).into_owned() - } - - // The wiring, not the renderer: `device_name` has to leave this - // command on the grant request, or the approval page goes back - // to naming every machine "Mergify CLI". Asserted against what - // this machine actually calls itself, so the test says the same - // thing on a laptop and in a container with no `hostname`. - #[test] - fn login_tells_the_server_which_machine_asked() { - with_mergify_token(None, async { - let body = grant_body(None).await; - match crate::machine::name().await { - Some(_) => assert!(body.contains("device_name="), "got {body:?}"), - None => assert_eq!(body, "client_id=mergify-cli", "got {body:?}"), - } - }); - } - - // `--device-name` replaces the hostname rather than joining it, - // and goes out as typed: the server sanitizes, not the CLI. - #[test] - fn device_name_overrides_the_hostname() { - with_mergify_token(None, async { - let body = grant_body(Some("work laptop")).await; - assert_eq!( - body, "client_id=mergify-cli&device_name=work+laptop", - "got {body:?}" - ); - }); - } - - // `--device-name ""` is the opt-out: no hostname leaves the - // machine, and the server keeps its own default label. - #[test] - fn empty_device_name_sends_none() { - with_mergify_token(None, async { - for blank in ["", " "] { - let body = grant_body(Some(blank)).await; - assert_eq!(body, "client_id=mergify-cli", "for {blank:?}, got {body:?}"); - } - }); - } - - // `--no-browser`: nothing is spawned, and the instructions go - // back to telling the user to open the URL themselves. - #[test] - fn no_browser_tells_the_user_to_open_the_url() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_flow(&server, true).await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LoginOptions { - api_url: Some(&server.uri()), - store: &store, - browser: None, - device_name: None, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stderr = captured.stderr(); - assert!( - stderr.contains("Open this URL to authorize"), - "got {stderr:?}", - ); - assert!(!stderr.contains("Opening your browser"), "got {stderr:?}"); - drop(dir); - }); - } - - #[test] - fn expires_at_turns_the_servers_lifetime_into_a_moment() { - let now = DateTime::parse_from_rfc3339("2026-09-04T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!( - expires_at(now, 31_536_000).unwrap().to_rfc3339(), - "2027-09-04T00:00:00+00:00", - ); - } - - #[test] - fn a_nonsensical_lifetime_yields_no_expiry_rather_than_a_panic() { - let now = DateTime::parse_from_rfc3339("2026-09-04T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - assert_eq!(expires_at(now, u64::MAX), None); - } -} diff --git a/crates/mergify-auth/src/logout.rs b/crates/mergify-auth/src/logout.rs deleted file mode 100644 index 5ff7b26b..00000000 --- a/crates/mergify-auth/src/logout.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! `mergify auth logout` — revoke the stored credential and forget it. - -use std::io::Write; - -use mergify_core::CliError; -use mergify_core::CredentialStore; -use mergify_core::Output; -use mergify_core::auth; -use serde::Serialize; -use url::Url; - -use crate::device; - -pub struct LogoutOptions<'a> { - pub api_url: Option<&'a str>, - pub store: &'a CredentialStore, -} - -#[derive(Serialize)] -struct LogoutResult { - api_url: String, - /// Whether there was a credential to remove. `false` is a - /// successful no-op, not a failure: `logout` promises that the - /// machine holds no credential afterwards, and it does. - was_logged_in: bool, - /// Whether the revocation request was accepted. It is not a - /// promise that the token is dead: RFC 7009 has the endpoint - /// answer 200 for any string, so this says the server took the - /// request, never that it found something to delete. - revoked: bool, - /// The environment variable that still authenticates every - /// Mergify command after this logout, if one is set. - overridden_by: Option, -} - -/// Run the `auth logout` command. -pub async fn run(opts: LogoutOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let api_url = auth::resolve_api_url(opts.api_url)?; - let overriding = auth::overriding_env_var(); - - let stored = opts.store.get(&api_url)?; - let Some(stored) = stored else { - // Still ask the store to forget: a keychain that refused to - // be *read* looks empty to `get` while holding the - // credential, and `delete` is the call that finds out. - let removed = opts.store.delete(&api_url).map_err(|e| { - // The store refused to confirm it is empty, which is the - // one shape of this branch that matters: a keychain can - // deny a *read* and still delete — macOS deletes an item - // whose read ACL it refuses, because deleting needs no - // decrypt. So a credential may have been removed without - // ever being read, and a token this command never saw is - // one it could not ask the server to revoke. - CliError::wrap( - "a credential for this API may have been removed from this machine without \ - being read, in which case the Mergify API was not told to revoke it — \ - revoke it from the CLI Tokens page of your Mergify dashboard", - e, - ) - })?; - return emit(output, &api_url, removed, false, overriding); - }; - - // Revoke first, then forget: the server is the copy that matters, - // and a token whose only trace was the file we just deleted can - // no longer be revoked from here at all. - let client = device::client(api_url.clone())?; - let revocation = device::revoke(&client, &stored.credential.token).await; - - // Removed either way. A `logout` that left the credential in - // place because the network blinked would have done nothing at - // all, which is the worse of the two half-states. - opts.store.delete(&api_url)?; - - // A failed revocation is loud but not fatal. The postcondition - // the user asked for — this machine no longer holds the - // credential — holds, and failing the command would make - // `auth logout` unusable in the teardown scripts that are - // exactly where it belongs. - let revoked = match revocation { - Ok(()) => true, - Err(e) => { - output.status(&format!( - "Warning: the credential was removed from this machine, but the Mergify API \ - could not be told to revoke it ({e}). Revoke it from the CLI Tokens page of \ - your Mergify dashboard.", - ))?; - false - } - }; - - // The note `emit` prints tells the user that `MERGIFY_TOKEN` - // keeps them authenticated. That is the opposite of true when - // the variable holds the token this command just revoked — - // which is the configuration this feature itself invites, since - // exporting the credential `auth login` mints is the obvious - // thing to do with it. - let overriding = if auth::overriding_env_var_holds(&stored.credential.token) { - if revoked { - output.status( - "Warning: MERGIFY_TOKEN holds the credential that was just revoked. Unset \ - it, or Mergify commands will keep sending a dead token.", - )?; - } - None - } else { - overriding - }; - - emit(output, &api_url, true, revoked, overriding) -} - -fn emit( - output: &mut dyn Output, - api_url: &Url, - was_logged_in: bool, - revoked: bool, - overriding: Option<&'static str>, -) -> Result<(), CliError> { - let result = LogoutResult { - api_url: api_url.to_string(), - was_logged_in, - revoked, - overridden_by: overriding.map(str::to_owned), - }; - let theme = mergify_tui::Theme::detect(); - output.emit(&result, &mut |w: &mut dyn Write| { - if was_logged_in { - // Says only what is true. The revocation endpoint - // answers 200 for any string and is rate limited per IP, - // so "the token is dead everywhere" is a claim this - // command is not in a position to make. - writeln!(w, "Logged out of {api_url}.")?; - } else { - writeln!(w, "No credential stored for {api_url}.")?; - } - // The more dangerous direction of the same omission `login` - // and `status` cover: the user believes access is gone. - if let Some(name) = overriding { - writeln!( - w, - "\n{warn}Note:{reset} {name} is still set, so Mergify commands remain \ - authenticated with it.", - warn = theme.warn.render(), - reset = theme.reset, - )?; - } - Ok(()) - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_core::Credential; - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_string_contains; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - use crate::testing::file_store; - use crate::testing::with_mergify_token; - - fn credential() -> Credential { - Credential { - token: "mut_secret".to_string(), - expires_at: None, - } - } - - #[test] - fn logout_revokes_server_side_and_forgets_locally() { - with_mergify_token(None, async { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .and(body_string_contains("token=mut_secret")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - let (dir, store) = file_store(); - let api_url = Url::parse(&server.uri()).unwrap(); - store.set(&api_url, &credential()).unwrap(); - let mut captured = Captured::human(); - - run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - assert_eq!(store.get(&api_url).unwrap(), None); - assert!(captured.stdout().contains("Logged out of")); - drop(dir); - }); - } - - // Nothing stored is a successful no-op: the postcondition the - // user asked for already holds. - #[test] - fn logout_without_a_credential_calls_nothing_and_succeeds() { - with_mergify_token(None, async { - let server = MockServer::start().await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - assert!(captured.stdout().contains("No credential stored")); - assert!( - server.received_requests().await.unwrap().is_empty(), - "logout must not call an API it has no credential for", - ); - drop(dir); - }); - } - - // The dangerous direction of the omission: the user believes - // access is gone, and every Mergify command still authenticates. - #[test] - fn logout_says_when_an_environment_credential_still_authenticates() { - let (dir, store) = file_store(); - let mut captured = Captured::human(); - let stdout = with_mergify_token(Some("mut_from_the_environment"), async { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - captured.stdout() - }); - - assert!(stdout.contains("Logged out of"), "got {stdout:?}"); - assert!( - stdout.contains("MERGIFY_TOKEN is still set"), - "got {stdout:?}", - ); - drop(dir); - } - - // A failed revocation still clears the machine, says so, and does - // not fail the command: the postcondition the user asked for - // holds, and `auth logout` belongs in teardown scripts. - // A keychain can refuse a *read* and still delete: macOS deletes - // an item whose read ACL it denies, because deleting needs no - // decrypt. `logout` then removes a credential it never saw, so - // there was no token to revoke — and the warning that says so - // lives in the branch `get` -> `None` reaches, not the other one. - #[test] - fn a_credential_removed_without_being_read_warns_that_it_was_not_revoked() { - with_mergify_token(None, async { - let server = MockServer::start().await; - let store = mergify_core::CredentialStore::with_unreadable_keychain("mut_secret"); - let mut captured = Captured::human(); - - let err = run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - - let message = err.to_string(); - assert!(message.contains("without being read"), "got {message:?}"); - assert!(message.contains("CLI Tokens"), "got {message:?}"); - assert!( - server.received_requests().await.unwrap().is_empty(), - "there was no token to revoke, so nothing may be sent", - ); - }); - } - - // `MERGIFY_TOKEN` holding the token that was just revoked makes - // the standing note exactly backwards. Exporting the credential - // `auth login` mints is the configuration this feature invites, - // so this is not a corner case. - #[test] - fn logout_says_to_unset_an_env_var_holding_the_revoked_credential() { - let (dir, store) = file_store(); - with_mergify_token(Some("mut_secret"), async { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stderr = captured.stderr(); - assert!( - stderr.contains("holds the credential that was just revoked"), - "got {stderr:?}", - ); - let stdout = captured.stdout(); - assert!( - !stdout.contains("remain authenticated"), - "the standing note is the opposite of true here: got {stdout:?}", - ); - }); - drop(dir); - } - - #[test] - fn a_failed_revocation_still_removes_the_local_credential() { - with_mergify_token(None, async { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/oauth/revoke")) - .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ - "error": "invalid_client", - "error_description": "Unknown client_id.", - }))) - .mount(&server) - .await; - let (dir, store) = file_store(); - let api_url = Url::parse(&server.uri()).unwrap(); - store.set(&api_url, &credential()).unwrap(); - let mut captured = Captured::human(); - - run( - LogoutOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - assert!( - captured.stderr().contains("CLI Tokens page"), - "got {:?}", - captured.stderr(), - ); - assert_eq!( - store.get(&api_url).unwrap(), - None, - "the local credential must be gone even when the revocation failed", - ); - drop(dir); - }); - } -} diff --git a/crates/mergify-auth/src/machine.rs b/crates/mergify-auth/src/machine.rs deleted file mode 100644 index 1672b077..00000000 --- a/crates/mergify-auth/src/machine.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! The name this machine calls itself. -//! -//! One caller: the `device_name` `auth login` sends when it opens a -//! grant, so the approval page's Token name field defaults to -//! `Mergify CLI on ` rather than to the static client name -//! every machine shares. The field is optional on the wire — a -//! machine that cannot name itself sends nothing and the server -//! keeps its own default — so nothing here is worth failing a login -//! over, and every failure is a `None`. - -use std::process::Command; -use std::time::Duration; - -/// How long `hostname` gets. It reads a name the kernel already -/// holds, so this is not a budget — it is the ceiling on how long a -/// wedged shim on `PATH` can hold up a login. `login` runs this -/// before anything is on screen, and a blank terminal is a worse -/// failure than a token named "Mergify CLI". -const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); - -/// The machine's hostname, as the machine reports it. -/// -/// `hostname(1)` first: it ships on all three platforms and is the -/// only answer that is still right after the name changes under a -/// running shell. The variables are the fallback for a container -/// with no `hostname` on `PATH`. -/// -/// Verbatim, on purpose. Not lower-cased, not stripped of a -/// `.local` suffix, not cut to length. The server sanitizes the -/// value (printable ASCII, collapsed whitespace, 60 characters) and -/// composes the label itself, so a client that trimmed first would -/// only disagree with it. -pub async fn name() -> Option { - // Off the runtime thread and on a deadline. The value decorates - // the approval page and nothing waits on it, so a `hostname` - // that does not come back is dropped rather than waited for. - let from_command = - tokio::time::timeout(LOOKUP_TIMEOUT, tokio::task::spawn_blocking(from_command)) - .await - .ok() - .and_then(Result::ok) - .flatten(); - from_command.or_else(from_env) -} - -fn from_command() -> Option { - let output = Command::new("hostname").output().ok()?; - if !output.status.success() { - return None; - } - // Lossy because a name is worth more than the byte that did not - // decode: the server maps anything outside printable ASCII to a - // space anyway. - as_name(&String::from_utf8_lossy(&output.stdout)) -} - -fn from_env() -> Option { - // `COMPUTERNAME` is Windows' own; `HOSTNAME` is exported by some - // shells and images and is worth asking for once the command is - // gone. - mergify_core::env::var_non_empty("COMPUTERNAME") - .or_else(|| mergify_core::env::var_non_empty("HOSTNAME")) - .as_deref() - .and_then(as_name) -} - -/// What a `hostname` process printed, as a name. -/// -/// Dropping the line's own newline is reading the command's output, -/// not sanitizing the value — what is inside the line goes to the -/// server as it stands. A name that is only whitespace is no name. -fn as_name(raw: &str) -> Option { - let trimmed = raw.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // Whatever this machine is called, what comes back has to be - // sendable: the newline `hostname` prints must not reach the - // form body. - #[tokio::test] - async fn the_name_of_the_machine_running_the_suite_is_sendable() { - if let Some(name) = name().await { - assert!(!name.is_empty()); - assert_eq!(name.trim(), name, "a name must carry no surrounding space"); - } - } - - #[test] - fn a_printed_name_loses_its_line_and_nothing_else() { - assert_eq!(as_name("work-laptop\n").as_deref(), Some("work-laptop")); - // The `.local` stays: the server composes and caps the - // label, and a client that trimmed here would disagree with - // what the approval page shows. - assert_eq!( - as_name("some-macbook.local\n").as_deref(), - Some("some-macbook.local"), - ); - } - - #[test] - fn a_machine_that_names_itself_nothing_has_no_name() { - assert_eq!(as_name(" \n"), None); - assert_eq!(as_name(""), None); - } - - // The container case: no `hostname` on PATH, and the image sets - // the variable instead. - #[test] - fn the_variables_are_the_fallback() { - let from_windows = mergify_core::env::testing::with_vars( - [ - ("COMPUTERNAME", Some("WIN-BOX")), - ("HOSTNAME", Some("ignored")), - ], - from_env, - ); - assert_eq!(from_windows.as_deref(), Some("WIN-BOX")); - - let from_shell = mergify_core::env::testing::with_vars( - [("COMPUTERNAME", None), ("HOSTNAME", Some("build-42"))], - from_env, - ); - assert_eq!(from_shell.as_deref(), Some("build-42")); - - let from_nothing = mergify_core::env::testing::with_vars( - [("COMPUTERNAME", None::<&str>), ("HOSTNAME", None)], - from_env, - ); - assert_eq!(from_nothing, None); - } -} diff --git a/crates/mergify-auth/src/status.rs b/crates/mergify-auth/src/status.rs deleted file mode 100644 index 5bda1569..00000000 --- a/crates/mergify-auth/src/status.rs +++ /dev/null @@ -1,663 +0,0 @@ -//! `mergify auth status` — is there a credential, whose is it, and -//! does the API still accept it? -//! -//! The last question is the one only the API can answer. A stored -//! token carries an expiry, but it can be revoked from the dashboard -//! long before that, so a `status` that only read the local file -//! would happily call a dead credential live. - -use std::io::Write; - -use chrono::DateTime; -use chrono::SecondsFormat; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CredentialStore; -use mergify_core::Output; -use mergify_core::auth; -use mergify_core::credentials::StoredCredential; -use serde::Serialize; -use url::Url; - -use crate::identity; -use crate::identity::Identity; - -pub struct StatusOptions<'a> { - pub api_url: Option<&'a str>, - pub store: &'a CredentialStore, -} - -#[derive(Serialize)] -struct StatusResult { - api_url: String, - login: Option, - stored_in: String, - expires_at: Option, - /// The environment variable Mergify commands would use instead - /// of the stored credential, if one is set. - overridden_by: Option, - /// Whether the API still accepts the *stored* credential. - /// `None` on a deployment that cannot say. - credential_valid: Option, -} - -/// What the API had to say about the stored credential, which is the -/// first line of the report. -#[derive(Copy, Clone)] -enum Headline<'a> { - /// It named the account the credential belongs to. - Account(&'a str), - /// The deployment predates `GET /v1/user`: there is a credential - /// and no way to check it. - Unverifiable, - /// The API refuses it. Reachable only when the environment - /// authenticates instead — otherwise nothing works and `run` - /// errors rather than printing a report. - Refused, -} - -/// Run the `auth status` command. -pub async fn run(opts: StatusOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let api_url = auth::resolve_api_url(opts.api_url)?; - - let overriding = auth::overriding_env_var(); - - let Some(stored) = opts.store.get(&api_url)? else { - // "Not logged in" would be the same lie as "logged in" is - // when a credential is overridden, pointing the other way: - // with `MERGIFY_TOKEN` exported every Mergify command - // authenticates fine, and telling the user to log in would - // send them to fix something that is not broken. - if let Some(name) = overriding { - return emit_env_only(output, &api_url, name); - } - return Err(CliError::Configuration(format!( - "not logged in to {api_url}. Run `mergify auth login`.", - ))); - }; - - // The same secret in both places is not an override worth a - // note: "commands use it instead of this credential" would be - // technically true and alarming about nothing. - let overriding = - overriding.filter(|_| !auth::overriding_env_var_holds(&stored.credential.token)); - - match identity::whoami(api_url.clone(), &stored.credential.token).await? { - Identity::Known(user) => emit( - output, - &api_url, - &stored, - Headline::Account(&user.login), - overriding, - Utc::now(), - ), - // The credential is real enough to be stored and useless - // enough that every command will fail. Saying "logged in" - // here would be the one answer a user cannot act on. - // - // Unless the environment outranks it, which it does for - // every command: then this is a stale credential sitting - // behind a working one, and exiting 8 with "run `mergify - // auth login`" would send the user to fix something while - // `queue status` and `tests show` succeed beside it. - Identity::Refused if overriding.is_some() => emit( - output, - &api_url, - &stored, - Headline::Refused, - overriding, - Utc::now(), - ), - Identity::Refused => Err(CliError::Configuration(format!( - "the credential stored for {api_url} is no longer valid — it may have been \ - revoked or have expired. Run `mergify auth login`.", - ))), - // A deployment older than `GET /v1/user`. There is a - // credential and no way to check it; report both. - Identity::Unsupported => emit( - output, - &api_url, - &stored, - Headline::Unverifiable, - overriding, - Utc::now(), - ), - } -} - -/// What to print when there is no stored credential but the -/// environment supplies one. Not a failure: every Mergify command -/// authenticates, which is what the user asked about. -fn emit_env_only( - output: &mut dyn Output, - api_url: &Url, - name: &'static str, -) -> Result<(), CliError> { - let result = StatusResult { - api_url: api_url.to_string(), - login: None, - stored_in: name.to_string(), - expires_at: None, - overridden_by: Some(name.to_string()), - // There is no stored credential to have an opinion about. - credential_valid: None, - }; - let theme = mergify_tui::Theme::detect(); - output.emit(&result, &mut |w: &mut dyn Write| { - writeln!( - w, - "{green}✓{reset} Authenticated to {api_url} with {name} from the environment.", - green = theme.green.render(), - reset = theme.reset, - )?; - writeln!( - w, - " No credential is stored on this machine. Run `mergify auth login` to store one." - ) - })?; - Ok(()) -} - -fn emit( - output: &mut dyn Output, - api_url: &Url, - stored: &StoredCredential, - headline: Headline<'_>, - overriding: Option<&'static str>, - now: DateTime, -) -> Result<(), CliError> { - // Seconds, not the nanoseconds `Utc::now()` carries: this is a - // date a year out, and six digits of subsecond precision on it - // is noise. - let expires_at = stored - .credential - .expires_at - .map(|at| at.to_rfc3339_opts(SecondsFormat::Secs, true)); - let expired = stored.credential.expires_at.is_some_and(|at| at <= now); - let result = StatusResult { - api_url: api_url.to_string(), - login: match headline { - Headline::Account(login) => Some(login.to_owned()), - Headline::Unverifiable | Headline::Refused => None, - }, - stored_in: stored.location.to_string(), - expires_at: expires_at.clone(), - overridden_by: overriding.map(str::to_owned), - credential_valid: match headline { - Headline::Account(_) => Some(true), - Headline::Unverifiable => None, - Headline::Refused => Some(false), - }, - }; - let theme = mergify_tui::Theme::detect(); - let location = stored.location.to_string(); - output.emit(&result, &mut |w: &mut dyn Write| { - match headline { - Headline::Account(login) => writeln!( - w, - "{green}✓{reset} Logged in to {api_url} as {login}.", - green = theme.green.render(), - reset = theme.reset, - )?, - Headline::Unverifiable => writeln!( - w, - "{green}✓{reset} Logged in to {api_url}. This deployment cannot confirm \ - which account the credential belongs to.", - green = theme.green.render(), - reset = theme.reset, - )?, - Headline::Refused => writeln!( - w, - "{warn}!{reset} The credential stored for {api_url} is no longer valid — it \ - may have been revoked or have expired.", - warn = theme.warn.render(), - reset = theme.reset, - )?, - } - writeln!(w, " Credential: {location}")?; - if let Some(at) = &expires_at { - // `relative_time` takes an absolute delta, so asking it - // for a future rendering of a past moment prints an - // expired credential as "expires in about a year". - let (label, relative) = if expired { - ("Expired:", mergify_tui::relative_time(at, now, false)) - } else { - ("Expires:", mergify_tui::relative_time(at, now, true)) - }; - writeln!(w, " {label:<11} {at} ({relative})")?; - } - // Without this line, "logged in" would be the answer to a - // question the user did not ask: what commands actually send - // is the environment variable, not the credential above. - if let Some(name) = overriding { - let remedy = match headline { - // The stored credential is dead and nothing depends - // on it, so the useful next step is replacing it — - // not unsetting the variable commands work with. - Headline::Refused => " Run `mergify auth login` to replace the stored one.", - Headline::Account(_) | Headline::Unverifiable => "", - }; - writeln!( - w, - "\n{warn}Note:{reset} {name} is set, so Mergify commands use it instead of \ - this credential.{remedy}", - warn = theme.warn.render(), - reset = theme.reset, - )?; - } - Ok(()) - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_core::Credential; - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - use crate::testing::file_store; - use crate::testing::with_mergify_token; - - fn credential() -> Credential { - Credential { - token: "mut_secret".to_string(), - expires_at: Some( - DateTime::parse_from_rfc3339("2027-09-04T00:00:00Z") - .unwrap() - .with_timezone(&Utc), - ), - } - } - - async fn mount_user(server: &MockServer, response: ResponseTemplate) { - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with(response) - .mount(server) - .await; - } - - #[test] - fn status_reports_the_account_the_store_and_the_expiry() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_user( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": 42, "login": "sileht"})), - ) - .await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("as sileht."), "got {stdout:?}"); - assert!(stdout.contains("credentials.json"), "got {stdout:?}"); - assert!(stdout.contains("2027-09-04"), "got {stdout:?}"); - assert!( - !stdout.contains("mut_secret"), - "the token must never be printed, got {stdout:?}", - ); - drop(dir); - }); - } - - // The inverse of the lie the override note prevents: with - // `MERGIFY_TOKEN` exported every Mergify command authenticates, - // so "not logged in" would send the user to fix something that - // is not broken. - #[test] - fn status_reports_an_environment_credential_when_nothing_is_stored() { - let (dir, store) = file_store(); - let mut captured = Captured::human(); - let stdout = with_mergify_token(Some("mut_from_the_environment"), async { - let server = MockServer::start().await; - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - captured.stdout() - }); - - assert!(stdout.contains("Authenticated to"), "got {stdout:?}"); - assert!(stdout.contains("MERGIFY_TOKEN"), "got {stdout:?}"); - assert!(stdout.contains("No credential is stored"), "got {stdout:?}"); - assert!( - !stdout.contains("mut_from_the_environment"), - "the token must never be printed, got {stdout:?}", - ); - drop(dir); - } - - // Same reason as in `login`: proving the renderer can print the - // note proves nothing about `run` asking it to. - #[test] - fn status_reads_the_overriding_env_var_from_the_environment() { - let (dir, store) = file_store(); - let mut captured = Captured::human(); - let stdout = with_mergify_token(Some("env-token"), async { - let server = MockServer::start().await; - mount_user( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": 42, "login": "sileht"})), - ) - .await; - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - captured.stdout() - }); - - assert!( - stdout.contains("MERGIFY_TOKEN is set, so Mergify commands use it"), - "got {stdout:?}", - ); - drop(dir); - } - - #[test] - fn status_without_a_credential_says_so_and_fails() { - with_mergify_token(None, async { - let server = MockServer::start().await; - let (dir, store) = file_store(); - let mut captured = Captured::human(); - - let err = run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - - assert!(err.to_string().contains("not logged in"), "got {err}"); - assert!(err.to_string().contains("auth login"), "got {err}"); - assert!( - server.received_requests().await.unwrap().is_empty(), - "with nothing stored there is nothing to check against the API", - ); - drop(dir); - }); - } - - // A credential the dashboard revoked is still on disk. Reading - // only the disk would call it live. - #[test] - fn status_reports_a_revoked_credential_as_invalid() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_user( - &server, - ResponseTemplate::new(403) - .set_body_json(serde_json::json!({"detail": "forbidden"})), - ) - .await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - let err = run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - - assert!(err.to_string().contains("no longer valid"), "got {err}"); - assert!(err.to_string().contains("auth login"), "got {err}"); - drop(dir); - }); - } - - fn stored() -> StoredCredential { - StoredCredential { - credential: credential(), - location: mergify_core::credentials::Location::Keychain, - } - } - - fn now() -> DateTime { - DateTime::parse_from_rfc3339("2026-09-04T00:00:00Z") - .unwrap() - .with_timezone(&Utc) - } - - // A stored credential the API refuses is fatal on its own — but - // not when the environment authenticates every command anyway. - // Exiting 8 there sends the user to fix something while - // `queue status` succeeds beside it. - #[test] - fn a_refused_credential_is_a_warning_when_the_environment_outranks_it() { - with_mergify_token(Some("mut_from_the_shell"), async { - let server = MockServer::start().await; - mount_user(&server, ResponseTemplate::new(403)).await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("no longer valid"), "got {stdout:?}"); - assert!(stdout.contains("MERGIFY_TOKEN is set"), "got {stdout:?}"); - assert!(stdout.contains("auth login"), "got {stdout:?}"); - drop(dir); - }); - } - - // And still fatal with nothing overriding it: every command will - // fail, so "logged in" is the one answer the user cannot act on. - #[test] - fn a_refused_credential_with_nothing_overriding_it_still_fails() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_user(&server, ResponseTemplate::new(403)).await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - let err = run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap_err(); - assert!(err.to_string().contains("no longer valid"), "got {err}"); - drop(dir); - }); - } - - // `MERGIFY_TOKEN` holding the *same* secret as the store is not - // an override: saying commands use it "instead" would be - // alarming about nothing. - #[test] - fn the_same_credential_in_the_environment_is_not_reported_as_an_override() { - with_mergify_token(Some("mut_secret"), async { - let server = MockServer::start().await; - mount_user( - &server, - ResponseTemplate::new(200) - .set_body_json(serde_json::json!({"id": 42, "login": "sileht"})), - ) - .await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("as sileht."), "got {stdout:?}"); - assert!(!stdout.contains("Note:"), "got {stdout:?}"); - drop(dir); - }); - } - - // The note that keeps "logged in" from being the answer to a - // question the user did not ask: a `MERGIFY_TOKEN` in the shell - // is what commands actually send. - #[test] - fn an_overriding_env_var_is_reported() { - let mut captured = Captured::human(); - emit( - &mut captured.output, - &Url::parse("https://api.mergify.com").unwrap(), - &stored(), - Headline::Account("sileht"), - Some("MERGIFY_TOKEN"), - now(), - ) - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("as sileht."), "got {stdout:?}"); - assert!( - stdout.contains("MERGIFY_TOKEN is set, so Mergify commands use it"), - "got {stdout:?}", - ); - } - - // `relative_time` works on an absolute delta, so a past moment - // asked for a future rendering reads as "expires in a year". - #[test] - fn an_expired_credential_is_not_rendered_as_a_future_one() { - let mut captured = Captured::human(); - let mut stored = stored(); - stored.credential.expires_at = Some( - DateTime::parse_from_rfc3339("2025-09-04T00:00:00Z") - .unwrap() - .with_timezone(&Utc), - ); - emit( - &mut captured.output, - &Url::parse("https://api.mergify.com").unwrap(), - &stored, - Headline::Unverifiable, - None, - now(), - ) - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("Expired:"), "got {stdout:?}"); - assert!(stdout.contains("ago"), "got {stdout:?}"); - assert!(!stdout.contains("(~"), "got {stdout:?}"); - } - - #[test] - fn no_note_when_nothing_overrides_the_credential() { - let mut captured = Captured::human(); - emit( - &mut captured.output, - &Url::parse("https://api.mergify.com").unwrap(), - &stored(), - Headline::Account("sileht"), - None, - now(), - ) - .unwrap(); - - let stdout = captured.stdout(); - assert!(!stdout.contains("Note:"), "got {stdout:?}"); - assert!(stdout.contains("the system keychain"), "got {stdout:?}"); - } - - #[test] - fn status_says_when_the_deployment_cannot_confirm_the_account() { - with_mergify_token(None, async { - let server = MockServer::start().await; - mount_user( - &server, - ResponseTemplate::new(404) - .set_body_json(serde_json::json!({"detail": "Not Found"})), - ) - .await; - let (dir, store) = file_store(); - store - .set(&Url::parse(&server.uri()).unwrap(), &credential()) - .unwrap(); - let mut captured = Captured::human(); - - run( - StatusOptions { - api_url: Some(&server.uri()), - store: &store, - }, - &mut captured.output, - ) - .await - .unwrap(); - - let stdout = captured.stdout(); - assert!(stdout.contains("Logged in to"), "got {stdout:?}"); - assert!(stdout.contains("cannot confirm"), "got {stdout:?}"); - drop(dir); - }); - } -} diff --git a/crates/mergify-ci/Cargo.toml b/crates/mergify-ci/Cargo.toml deleted file mode 100644 index 7ff0bb08..00000000 --- a/crates/mergify-ci/Cargo.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "mergify-ci" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify ci` subcommands." -publish = false - -[dependencies] -chrono = { workspace = true } -flate2 = { workspace = true } -getrandom = { workspace = true } -glob = { workspace = true } -mergify-config = { path = "../mergify-config" } -mergify-core = { path = "../mergify-core" } -opentelemetry-proto = { workspace = true } -prost = { workspace = true } -quick-xml = { workspace = true } -regex = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml_ng = { workspace = true } -tokio = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -mergify-test-support = { path = "../mergify-test-support" } -tempfile = { workspace = true } -tokio = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-ci/src/detector.rs b/crates/mergify-ci/src/detector.rs deleted file mode 100644 index 56001a1a..00000000 --- a/crates/mergify-ci/src/detector.rs +++ /dev/null @@ -1,1034 +0,0 @@ -//! CI environment detection — Rust mirror of -//! `mergify_cli/ci/detector.py`. -//! -//! Every public item here corresponds to a Python function or -//! constant of the same name. Only the items consumed by ported -//! Rust commands are mirrored; the rest stays in Python until its -//! command is ported. - -use mergify_core::env; - -use mergify_core::CliError; -use mergify_core::auth; -use url::Url; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CIProvider { - GithubActions, - CircleCi, - Jenkins, - Buildkite, -} - -impl CIProvider { - /// String identifier Python emits as the `cicd.provider.name` - /// span attribute. Must match `mergify_cli.ci.detector.CIProviderT` - /// (`snake_case`, no underscore for the multi-word ones except - /// `github_actions`). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::GithubActions => "github_actions", - Self::CircleCi => "circleci", - Self::Jenkins => "jenkins", - Self::Buildkite => "buildkite", - } - } -} - -#[must_use] -pub fn get_ci_provider() -> Option { - if env::var_non_empty("JENKINS_URL").is_some() { - return Some(CIProvider::Jenkins); - } - if env::var("GITHUB_ACTIONS").as_deref() == Some("true") { - return Some(CIProvider::GithubActions); - } - if env::var("CIRCLECI").as_deref() == Some("true") { - return Some(CIProvider::CircleCi); - } - if env::var("BUILDKITE").as_deref() == Some("true") { - return Some(CIProvider::Buildkite); - } - None -} - -/// Mirror of Python's private ``_get_github_repository_from_env``. -/// Reads ``env_name`` from the process environment and parses the -/// repository URL into ``owner/repo``. Returns ``None`` when the var -/// is unset or the value doesn't parse. -fn get_github_repository_from_env(env_name: &str) -> Option { - let raw = env::var(env_name)?; - parse_repository_url(&raw) -} - -fn parse_repository_url(url_str: &str) -> Option { - let url_str = url_str.trim(); - if url_str.is_empty() { - return None; - } - - if let Some(rest) = url_str.strip_prefix("git@") { - let (_host, path) = rest.split_once(':')?; - return validate_owner_repo(path.trim_end_matches('/').trim_end_matches(".git")); - } - - if url_str.starts_with("http://") || url_str.starts_with("https://") { - let parsed = Url::parse(url_str).ok()?; - // Python's regex anchors to end-of-string, so a URL carrying - // a query or fragment never matches. Reject them here too, - // otherwise `https://github.com/owner/repo?tab=readme` would - // parse to `owner/repo` in Rust but be ignored by Python. - if parsed.query().is_some() || parsed.fragment().is_some() { - return None; - } - let path = parsed - .path() - .trim_start_matches('/') - .trim_end_matches('/') - .trim_end_matches(".git"); - return validate_owner_repo(path); - } - - validate_owner_repo(url_str.trim_end_matches('/').trim_end_matches(".git")) -} - -fn validate_owner_repo(path: &str) -> Option { - let (owner, repo) = path.split_once('/')?; - if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') { - return None; - } - Some(format!("{owner}/{repo}")) -} - -/// Allowed character set for an `owner` or `repo` path segment. -/// -/// Matches GitHub's allowance (alphanumerics, `_`, `.`, `-`) and the -/// regex used by `parse_repository_url`. Rejects every URL-reserved -/// character (`?`, `#`, `%`, `/`, space) so callers can interpolate -/// the segments straight into a request path without percent-encoding -/// and without enabling path or query injection. -fn is_valid_segment(segment: &str) -> bool { - !segment.is_empty() - && segment - .chars() - .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-') -} - -/// Clap `value_parser` for `--repository`. Returning `Result<_, String>` -/// makes clap surface a bad value as exit code 2 instead of letting it -/// slip through to runtime as a `Configuration` error. -/// -/// # Errors -/// -/// Returns the validation message from `split_owner_repo` when the -/// input is not exactly `owner/repo` with allowed characters. -pub fn parse_owner_repo(value: &str) -> Result { - split_owner_repo(value) - .map(|_| value.to_string()) - .map_err(|e| e.to_string()) -} - -/// Split a `"owner/repo"` string into its two parts. The -/// Mergify CI Insights endpoints take owner and repository name as -/// separate path segments, while `--repository` accepts the -/// `owner/repo` shorthand. Rejects empty parts and any character -/// outside `is_valid_segment` so the values can be interpolated into -/// URL paths without further escaping. -pub fn split_owner_repo(value: &str) -> Result<(&str, &str), CliError> { - let mismatch = || { - CliError::Configuration(format!( - "invalid repository {value:?}: expected `owner/repo`", - )) - }; - let (owner, repo) = value.split_once('/').ok_or_else(mismatch)?; - if !is_valid_segment(owner) || !is_valid_segment(repo) || repo.contains('/') { - return Err(mismatch()); - } - Ok((owner, repo)) -} - -#[must_use] -pub fn get_github_repository() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("GITHUB_REPOSITORY"), - CIProvider::CircleCi => get_github_repository_from_env("CIRCLE_REPOSITORY_URL"), - CIProvider::Jenkins => get_github_repository_from_env("GIT_URL"), - CIProvider::Buildkite => get_github_repository_from_env("BUILDKITE_REPO"), - } -} - -/// Resolve the target repository for a CI Insights command, in -/// precedence order: an explicit `--repository` value, then the CI -/// environment (via [`get_github_repository`]), then the local git -/// remote (via [`auth::repository_from_git_remote`]). CI detection -/// wins over the git remote so a checkout with a different `origin` -/// can't override the repository the job is actually running for. -/// -/// The result is validated to a clean `owner/repo` regardless of its -/// source, so callers can interpolate it into a request path without -/// re-checking or percent-encoding. -/// -/// # Errors -/// -/// Returns `Configuration` when no source yields a repository, or -/// when the resolved value isn't a valid `owner/repo`. -pub fn resolve_repository(explicit: Option<&str>) -> Result { - let repository = explicit - .filter(|s| !s.is_empty()) - .map(str::to_string) - .or_else(get_github_repository) - .or_else(auth::repository_from_git_remote) - .ok_or_else(|| { - CliError::Configuration( - "--repository not provided and could not be detected from the CI \ - environment or the local git remote" - .to_string(), - ) - })?; - // The git-remote fallback (`parse_slug`) doesn't enforce the - // owner/repo character set that CI detection and the `--repository` - // value parser do; validate every source here so a stray - // `remote.origin.url` can't slip extra path segments or reserved - // characters into a request URL downstream. - split_owner_repo(&repository)?; - Ok(repository) -} - -pub fn get_github_pull_request_number() -> Result, CliError> { - match get_ci_provider() { - Some(CIProvider::GithubActions) => read_github_event_pull_request_number(), - Some(CIProvider::Buildkite) => match env::var("BUILDKITE_PULL_REQUEST") { - Some(pr) if !pr.is_empty() && pr != "false" => { - pr.parse::().map(Some).map_err(|e| { - CliError::Configuration(format!( - "BUILDKITE_PULL_REQUEST is not an integer: {e}" - )) - }) - } - _ => Ok(None), - }, - _ => Ok(None), - } -} - -fn read_github_event_pull_request_number() -> Result, CliError> { - // The PR-number lookup is strict about JSON failures because - // it's the only signal that decides whether `scopes-send` runs - // at all — silently swallowing a parse error there would hide - // a misconfigured workflow. The head-SHA lookup (see - // [`read_github_event_pull_request_head_sha`]) stays lenient - // because every one of its callers has somewhere to go without - // an answer. - let Some(event_path) = env::var_non_empty("GITHUB_EVENT_PATH") else { - return Ok(None); - }; - let content = match std::fs::read_to_string(&event_path) { - Ok(content) => content, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(e) => { - return Err(CliError::Configuration(format!( - "cannot read GITHUB_EVENT_PATH ({event_path}): {e}" - ))); - } - }; - let event: serde_json::Value = serde_json::from_str(&content).map_err(|e| { - CliError::Configuration(format!("GITHUB_EVENT_PATH is not valid JSON: {e}")) - })?; - Ok(event - .pointer("/pull_request/number") - .and_then(serde_json::Value::as_u64)) -} - -/// Whether `value` is a full 40-character hex SHA-1 object name, in -/// either case. -/// -/// Sibling to `git_refs::is_object_name`, which also admits a -/// 64-character SHA-256 name and rejects uppercase: that one gates an -/// untrusted pull request body being spliced into a git invocation, -/// where the narrowest spelling wins. This one answers "did the -/// provider name a revision", and GitHub reports pull request heads as -/// SHA-1 in lowercase while a hand-passed value may be uppercase. -pub(crate) fn is_sha1_object_name(value: &str) -> bool { - value.len() == 40 && value.bytes().all(|b| b.is_ascii_hexdigit()) -} - -/// Head SHA of the pull request the build is for. `None` unless a -/// provider names one as a full 40-character hex SHA — anything else -/// (`BUILDKITE_COMMIT` unset, which `git_refs` reads as the literal -/// `HEAD`) is not a revision and is dropped here, so callers can use -/// the answer without re-checking its shape. -/// -/// Deliberately not [`get_head_sha`], which answers the different -/// question "which revision did the tests run against" and falls back -/// to `GITHUB_SHA`. That fallback is wrong for identifying a pull -/// request head: on a `pull_request` event `GITHUB_SHA` is the -/// synthetic merge commit, and on `pull_request_target` it is the base -/// branch. Neither is any pull request's head, so a report keyed on -/// one would be stored where nothing ever reads it — a silent no-op, -/// worse than having no SHA at all and saying so. -/// -/// Jenkins is the one provider left uncovered, and deliberately: the -/// GitHub Branch Source plugin builds a pull request by merging it into -/// its target by default, so `GIT_COMMIT` is that merge commit rather -/// than the head — the same trap as `GITHUB_SHA`, with no second env -/// var to tell the two configurations apart. -#[must_use] -pub fn get_github_pull_request_head_sha() -> Option { - let sha = match get_ci_provider()? { - CIProvider::GithubActions => read_github_event_pull_request_head_sha(), - // Buildkite and CircleCI both build a pull request from its head - // commit, so their revision var *is* that head (`git_refs` reads - // `BUILDKITE_COMMIT` for the same purpose). - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_COMMIT"), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_SHA1"), - CIProvider::Jenkins => None, - }?; - is_sha1_object_name(&sha).then_some(sha) -} - -/// Clap `value_parser` for a `--head-sha` flag. Returning -/// `Result<_, String>` makes clap reject a bad value with exit code 2 -/// rather than letting it reach the API as a 422 — same treatment as -/// [`parse_owner_repo`]. -/// -/// # Errors -/// -/// Returns a message when `value` is not a full 40-character hex SHA. -pub fn parse_head_sha(value: &str) -> Result { - if is_sha1_object_name(value) { - return Ok(value.to_string()); - } - Err(format!( - "invalid head SHA {value:?}: expected 40 hexadecimal characters", - )) -} - -/// `cicd.pipeline.name` resource attribute. None when the -/// provider can't be detected or its env var isn't set. -#[must_use] -pub fn get_pipeline_name() -> Option { - let var = match get_ci_provider()? { - CIProvider::GithubActions => "GITHUB_WORKFLOW", - CIProvider::Jenkins => "JOB_NAME", - CIProvider::Buildkite => "BUILDKITE_PIPELINE_SLUG", - CIProvider::CircleCi => return None, - }; - env::var_non_empty(var) -} - -/// `cicd.pipeline.task.name` — the job within a pipeline. -#[must_use] -pub fn get_job_name() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("GITHUB_JOB"), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_JOB"), - CIProvider::Jenkins => env::var_non_empty("JOB_NAME"), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_LABEL") - .or_else(|| env::var_non_empty("BUILDKITE_STEP_KEY")), - } -} - -/// `vcs.ref.head.name` — name of the branch the test ran on. -#[must_use] -pub fn get_head_ref_name() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => { - // GitHub Actions sets `GITHUB_HEAD_REF` only on PR - // events. Fall back to `GITHUB_REF_NAME` everywhere - // else (the bare branch name, not `/merge`). - env::var_non_empty("GITHUB_HEAD_REF").or_else(|| env::var_non_empty("GITHUB_REF_NAME")) - } - CIProvider::CircleCi => env::var_non_empty("CIRCLE_BRANCH"), - CIProvider::Jenkins => env::var_non_empty("GIT_BRANCH").map(|raw| { - // Jenkins' Git plugin sets `GIT_BRANCH` to - // `/` (or `refs/heads/` when - // the job's configured for a refspec). Strip the - // common prefixes so the wire value matches what - // GitHub Actions reports. - for prefix in ["origin/", "refs/heads/"] { - if let Some(stripped) = raw.strip_prefix(prefix) { - return stripped.to_string(); - } - } - raw - }), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_BRANCH"), - } -} - -/// `vcs.ref.base.name` — PR target branch, when running for a PR. -#[must_use] -pub fn get_base_ref_name() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("GITHUB_BASE_REF"), - CIProvider::Jenkins => env::var_non_empty("CHANGE_TARGET"), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_PULL_REQUEST_BASE_BRANCH"), - CIProvider::CircleCi => None, - } -} - -/// `cicd.pipeline.runner.name` — host / agent identity. -#[must_use] -pub fn get_cicd_pipeline_runner_name() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("RUNNER_NAME"), - CIProvider::Jenkins => env::var_non_empty("NODE_NAME"), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_AGENT_NAME"), - CIProvider::CircleCi => None, - } -} - -/// `cicd.pipeline.run.id` — the workflow / build identifier. -/// Returned as a string because GitHub uses an integer-like ID -/// while Jenkins and Buildkite emit free-form strings. -#[must_use] -pub fn get_cicd_pipeline_run_id() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("GITHUB_RUN_ID"), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_WORKFLOW_ID"), - CIProvider::Jenkins => env::var_non_empty("BUILD_ID"), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_BUILD_ID"), - } -} - -/// `cicd.pipeline.run.attempt` — 1-indexed retry counter. -#[must_use] -pub fn get_cicd_pipeline_run_attempt() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => env::var_non_empty("GITHUB_RUN_ATTEMPT")?.parse().ok(), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_BUILD_NUM")?.parse().ok(), - // Buildkite uses 0-indexed retries; add 1 so a fresh run - // reads as attempt 1 (matching the GHA/CircleCI semantics). - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_RETRY_COUNT")? - .parse::() - .ok() - .map(|n| n + 1), - CIProvider::Jenkins => None, - } -} - -/// `cicd.pipeline.run.url` — direct link to the running build. -#[must_use] -pub fn get_cicd_pipeline_run_url() -> Option { - match get_ci_provider()? { - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_BUILD_URL"), - _ => None, - } -} - -/// `vcs.repository.url.full` — clone URL of the repository under -/// test. GitHub Actions has no equivalent env (the repo is implicit -/// from `GITHUB_REPOSITORY`); we report `None` there. -#[must_use] -pub fn get_repository_url() -> Option { - match get_ci_provider()? { - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_REPO"), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_REPOSITORY_URL"), - CIProvider::Jenkins => env::var_non_empty("GIT_URL"), - CIProvider::GithubActions => None, - } -} - -/// `vcs.ref.head.revision` — the commit SHA the tests ran against. -/// -/// For GitHub Actions PR builds, `GITHUB_SHA` is the *synthetic -/// merge commit* GitHub creates by merging the PR head into the -/// base — not the actual code under test. The event payload at -/// `GITHUB_EVENT_PATH` carries the real `pull_request.head.sha`, -/// which is what dashboards correlate with the contributor's -/// commit. We prefer the event-payload value when present and -/// fall back to `GITHUB_SHA` otherwise. -/// -/// For other providers we only have the bare env var today; the -/// `CircleCI` PR-build API fallback Python implements stays -/// Python-side until a Rust HTTP shim for GitHub's REST API lands. -#[must_use] -pub fn get_head_sha() -> Option { - match get_ci_provider()? { - CIProvider::GithubActions => get_github_actions_head_sha(), - CIProvider::CircleCi => env::var_non_empty("CIRCLE_SHA1"), - CIProvider::Jenkins => env::var_non_empty("GIT_COMMIT"), - CIProvider::Buildkite => env::var_non_empty("BUILDKITE_COMMIT"), - } -} - -fn get_github_actions_head_sha() -> Option { - if env::var("GITHUB_EVENT_NAME").as_deref() == Some("pull_request") - && let Some(sha) = read_github_event_pull_request_head_sha() - { - return Some(sha); - } - env::var_non_empty("GITHUB_SHA") -} - -/// Read `GITHUB_EVENT_PATH` and pluck the -/// `pull_request.head.sha` out of the JSON. Returns `None` for -/// every "not applicable" case — env unset, file missing, file -/// not JSON, key not present — so a caller can quietly take its own -/// no-answer branch without surfacing an error to the user. That is -/// `GITHUB_SHA` for [`get_head_sha`] and no head at all for -/// [`get_github_pull_request_head_sha`]. -fn read_github_event_pull_request_head_sha() -> Option { - let event = read_github_event_json()?; - event - .pointer("/pull_request/head/sha") - .and_then(serde_json::Value::as_str) - .map(str::to_string) -} - -fn read_github_event_json() -> Option { - let event_path = env::var("GITHUB_EVENT_PATH")?; - if event_path.is_empty() { - return None; - } - let content = std::fs::read_to_string(&event_path).ok()?; - serde_json::from_str(&content).ok() -} - -/// Branch the quarantine API should look up tests for. Mirrors -/// Python's `get_tests_target_branch`: the PR base branch when -/// available, otherwise the head branch — i.e. "the branch the -/// tests *will* land on" so quarantine decisions match where -/// the merge will go. -#[must_use] -pub fn get_tests_target_branch() -> Option { - get_base_ref_name().or_else(get_head_ref_name) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testing::write_github_event; - - #[test] - fn ci_provider_jenkins_takes_precedence() { - env::testing::with_vars( - [ - ("JENKINS_URL", Some("http://jenkins")), - ("GITHUB_ACTIONS", Some("true")), - ("CIRCLECI", Some("true")), - ("BUILDKITE", Some("true")), - ], - || { - assert_eq!(get_ci_provider(), Some(CIProvider::Jenkins)); - }, - ); - } - - #[test] - fn ci_provider_returns_none_when_unset() { - env::testing::with_no_vars(|| { - assert_eq!(get_ci_provider(), None); - }); - } - - #[test] - fn github_repository_github_actions() { - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ], - || { - assert_eq!(get_github_repository().as_deref(), Some("owner/repo")); - }, - ); - } - - #[test] - fn github_repository_buildkite_ssh() { - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_REPO", Some("git@github.com:owner/repo.git")), - ], - || { - assert_eq!(get_github_repository().as_deref(), Some("owner/repo")); - }, - ); - } - - #[test] - fn github_repository_buildkite_https() { - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_REPO", Some("https://github.com/owner/repo")), - ], - || { - assert_eq!(get_github_repository().as_deref(), Some("owner/repo")); - }, - ); - } - - #[test] - fn github_repository_circleci() { - env::testing::with_vars( - [ - ("CIRCLECI", Some("true")), - ( - "CIRCLE_REPOSITORY_URL", - Some("git@github.com:owner/repo.git"), - ), - ], - || { - assert_eq!(get_github_repository().as_deref(), Some("owner/repo")); - }, - ); - } - - #[test] - fn github_repository_jenkins() { - env::testing::with_vars( - [ - ("JENKINS_URL", Some("http://jenkins")), - ("GIT_URL", Some("https://github.com/owner/repo.git")), - ], - || { - assert_eq!(get_github_repository().as_deref(), Some("owner/repo")); - }, - ); - } - - #[test] - fn github_repository_returns_none_with_no_provider() { - env::testing::with_vars([("GITHUB_REPOSITORY", Some("owner/repo"))], || { - assert_eq!(get_github_repository(), None); - }); - } - - #[test] - fn resolve_repository_prefers_flag_over_env() { - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("env/env")), - ], - || { - assert_eq!(resolve_repository(Some("cli/cli")).unwrap(), "cli/cli"); - }, - ); - } - - #[test] - fn resolve_repository_prefers_ci_env_over_git_remote() { - // With no flag but a CI provider present, the CI repository is - // used — even though this test runs inside a git checkout whose - // `origin` would otherwise resolve to a different slug. Asserts - // both the CI fallback and its precedence over the git remote. - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ], - || { - assert_eq!(resolve_repository(None).unwrap(), "owner/repo"); - }, - ); - } - - #[test] - fn resolve_repository_rejects_invalid_slug() { - // The resolved value is validated regardless of source. The - // git-remote fallback (`parse_slug`) accepts multi-segment - // paths that would inject extra request-path segments; an - // explicit value exercises the same guard deterministically. - env::testing::with_no_vars(|| { - assert!(matches!( - resolve_repository(Some("owner/repo/extra")), - Err(CliError::Configuration(_)) - )); - }); - } - - // The no-flag/no-CI path falls through to the local git remote - // (`auth::repository_from_git_remote`); its parsing and the - // not-a-repo `None` case are covered in `mergify_core::auth`. The - // `Configuration` error only fires with neither a CI env nor a git - // remote, which can't be reproduced from inside this checkout. - - #[test] - fn pull_request_buildkite_reads_env() { - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_PULL_REQUEST", Some("42")), - ], - || { - assert_eq!(get_github_pull_request_number().unwrap(), Some(42)); - }, - ); - } - - #[test] - fn pull_request_buildkite_returns_none_when_false() { - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_PULL_REQUEST", Some("false")), - ], - || { - assert_eq!(get_github_pull_request_number().unwrap(), None); - }, - ); - } - - #[test] - fn pull_request_buildkite_returns_none_when_unset() { - env::testing::with_vars([("BUILDKITE", Some("true"))], || { - assert_eq!(get_github_pull_request_number().unwrap(), None); - }); - } - - #[test] - fn pull_request_returns_none_with_no_provider() { - env::testing::with_no_vars(|| { - assert_eq!(get_github_pull_request_number().unwrap(), None); - }); - } - - #[test] - fn pull_request_github_actions_reads_event_json() { - let tmp = tempfile::tempdir().unwrap(); - let event_path = tmp.path().join("event.json"); - std::fs::write( - &event_path, - serde_json::json!({ "pull_request": { "number": 123 } }).to_string(), - ) - .unwrap(); - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ], - || { - assert_eq!(get_github_pull_request_number().unwrap(), Some(123)); - }, - ); - } - - #[test] - fn pull_request_github_actions_missing_event_file_returns_none() { - let tmp = tempfile::tempdir().unwrap(); - let missing = tmp.path().join("nope.json"); - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_PATH", Some(missing.to_str().unwrap())), - ], - || { - assert_eq!(get_github_pull_request_number().unwrap(), None); - }, - ); - } - - #[test] - fn parse_head_sha_accepts_a_full_sha_in_either_case() { - let lower = "feedface00000000000000000000000000000000"; - assert_eq!(parse_head_sha(lower).unwrap(), lower); - // The engine folds case, so an uppercase spelling names the - // same revision rather than a different one. - let upper = lower.to_uppercase(); - assert_eq!(parse_head_sha(&upper).unwrap(), upper); - } - - #[test] - fn parse_head_sha_rejects_anything_that_is_not_a_full_sha1() { - for bad in [ - "", - "HEAD", - // Abbreviated: never equal to a stored full-length head. - "feedface", - // 40 characters, but not hex. - "zeedface00000000000000000000000000000000", - // A SHA-256 object name: `git_refs::is_object_name` takes - // one, this does not — GitHub reports pull request heads - // as SHA-1. - &"a".repeat(64), - ] { - assert!(parse_head_sha(bad).is_err(), "accepted {bad:?}"); - } - } - - #[test] - fn pull_request_head_sha_reads_the_github_event_payload() { - let tmp = tempfile::tempdir().unwrap(); - let event_path = write_github_event( - tmp.path(), - &serde_json::json!({ - "pull_request": {"head": {"sha": "feedface00000000000000000000000000000000"}}, - }), - ); - - for event_name in ["pull_request", "pull_request_target"] { - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some(event_name)), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - // Never the answer, on either event — see - // `get_github_pull_request_head_sha`. - ( - "GITHUB_SHA", - Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - ), - ], - || { - assert_eq!( - get_github_pull_request_head_sha().as_deref(), - Some("feedface00000000000000000000000000000000"), - "event {event_name}", - ); - }, - ); - } - } - - #[test] - fn pull_request_head_sha_is_none_without_a_pull_request_in_the_payload() { - // Where `get_head_sha` falls back to `GITHUB_SHA`, this must - // not. - let tmp = tempfile::tempdir().unwrap(); - let event_path = write_github_event(tmp.path(), &serde_json::json!({})); - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("push")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ( - "GITHUB_SHA", - Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - ), - ], - || { - assert_eq!(get_github_pull_request_head_sha(), None); - }, - ); - } - - #[test] - fn pull_request_head_sha_uses_buildkite_commit() { - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ( - "BUILDKITE_COMMIT", - Some("0123456789abcdef0123456789abcdef01234567"), - ), - ], - || { - assert_eq!( - get_github_pull_request_head_sha().as_deref(), - Some("0123456789abcdef0123456789abcdef01234567"), - ); - }, - ); - } - - #[test] - fn pull_request_head_sha_drops_a_value_that_is_not_a_revision() { - // An unset `BUILDKITE_COMMIT` is what `git_refs` reads as the - // literal `HEAD`; callers must not have to re-check the shape. - env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_COMMIT", Some("HEAD")), - ], - || { - assert_eq!(get_github_pull_request_head_sha(), None); - }, - ); - } - - #[test] - fn pull_request_head_sha_uses_circle_sha1() { - env::testing::with_vars( - [ - ("CIRCLECI", Some("true")), - ( - "CIRCLE_SHA1", - Some("0123456789abcdef0123456789abcdef01234567"), - ), - ], - || { - assert_eq!( - get_github_pull_request_head_sha().as_deref(), - Some("0123456789abcdef0123456789abcdef01234567"), - ); - }, - ); - } - - #[test] - fn pull_request_head_sha_is_none_on_jenkins() { - // `GIT_COMMIT` is the merge commit whenever the Branch Source - // plugin builds a pull request merged into its target, which is - // its default, and nothing distinguishes that from the head-only - // configuration. No answer beats the wrong one. - env::testing::with_vars( - [ - ("JENKINS_URL", Some("http://ci")), - ( - "GIT_COMMIT", - Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - ), - ], - || { - assert_eq!(get_github_pull_request_head_sha(), None); - }, - ); - } - - #[test] - fn split_owner_repo_accepts_owner_repo() { - assert_eq!( - split_owner_repo("Mergifyio/monorepo").unwrap(), - ("Mergifyio", "monorepo") - ); - assert_eq!(split_owner_repo("a/b").unwrap(), ("a", "b")); - } - - #[test] - fn split_owner_repo_rejects_inputs_without_exactly_one_slash() { - for bad in ["", "owner", "owner/", "/repo", "a/b/c", "/", "//"] { - let err = split_owner_repo(bad).unwrap_err(); - assert!( - matches!(err, CliError::Configuration(_)), - "input {bad:?} should map to Configuration, got {err:?}", - ); - assert!( - err.to_string().contains("owner/repo"), - "error for {bad:?} should mention expected shape, got: {err}", - ); - } - } - - #[test] - fn split_owner_repo_rejects_url_reserved_characters() { - // These would otherwise inject extra path or query segments - // when interpolated into a request URL. - for bad in [ - "owner/repo?x=1", - "owner/repo#frag", - "owner/repo%2e", - "own er/repo", - "owner /repo", - "owner/re po", - ] { - let err = split_owner_repo(bad).unwrap_err(); - assert!( - matches!(err, CliError::Configuration(_)), - "input {bad:?} should map to Configuration, got {err:?}", - ); - } - } - - #[test] - fn parse_repository_url_handles_known_shapes() { - let cases = [ - ("git@github.com:owner/repo.git", Some("owner/repo")), - ("git@github.com:owner/repo", Some("owner/repo")), - ("git@gitlab.example.com:owner/repo.git", Some("owner/repo")), - ("https://github.com/owner/repo", Some("owner/repo")), - ("https://github.com/owner/repo.git", Some("owner/repo")), - ("https://github.com/owner/repo/", Some("owner/repo")), - ("http://github.com:8080/owner/repo", Some("owner/repo")), - ("owner/repo", Some("owner/repo")), - ("https://github.com/owner/repo/sub", None), - // Python's regex anchors at end-of-string, so URLs with - // a query or fragment never match. - ("https://github.com/owner/repo?tab=readme", None), - ("https://github.com/owner/repo.git?ref=main", None), - ("https://github.com/owner/repo#readme", None), - ("not-a-url", None), - ("", None), - ]; - for (input, expected) in cases { - assert_eq!( - parse_repository_url(input).as_deref(), - expected, - "parse_repository_url({input:?}) mismatch" - ); - } - } - - #[test] - fn head_sha_prefers_pr_event_payload_over_github_sha() { - // PR events: `GITHUB_SHA` is the synthetic merge commit - // GitHub creates by pre-merging the PR; the contributor's - // actual head sha lives in the event payload at - // `pull_request.head.sha`. Dashboards correlate with the - // payload value, so we must prefer it. - let tmp = tempfile::tempdir().unwrap(); - let event_path = tmp.path().join("event.json"); - std::fs::write( - &event_path, - serde_json::json!({ - "pull_request": { - "number": 7, - "head": { "sha": "feedface00000000000000000000000000000000" } - } - }) - .to_string(), - ) - .unwrap(); - - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ( - "GITHUB_SHA", - Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - ), - ], - || { - assert_eq!( - get_head_sha().as_deref(), - Some("feedface00000000000000000000000000000000"), - ); - }, - ); - } - - #[test] - fn head_sha_falls_back_to_github_sha_when_event_lacks_pr_head() { - // push events still leave `GITHUB_EVENT_PATH` pointing at a - // payload, but it has no `pull_request` field. Fall back to - // `GITHUB_SHA` rather than returning None. - let tmp = tempfile::tempdir().unwrap(); - let event_path = tmp.path().join("event.json"); - std::fs::write(&event_path, serde_json::json!({}).to_string()).unwrap(); - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("push")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ("GITHUB_SHA", Some("deadbeef")), - ], - || { - assert_eq!(get_head_sha().as_deref(), Some("deadbeef")); - }, - ); - } - - #[test] - fn head_sha_uses_github_sha_when_event_path_missing() { - // Workflows without an event file (e.g. local - // `act` runs) still set GITHUB_SHA — we must not regress - // to `None` just because the JSON file isn't there. - env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some("/this/path/does/not/exist")), - ("GITHUB_SHA", Some("cafef00d")), - ], - || { - assert_eq!(get_head_sha().as_deref(), Some("cafef00d")); - }, - ); - } -} diff --git a/crates/mergify-ci/src/git.rs b/crates/mergify-ci/src/git.rs deleted file mode 100644 index b17de0e4..00000000 --- a/crates/mergify-ci/src/git.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Tiny helpers for shelling out to `git`. -//! -//! The engine publishes merge-queue metadata as git notes under -//! `refs/notes/mergify/*`. Both [`crate::git_refs`] and -//! [`crate::queue_info`] fetch and read those notes the same way: run -//! git, silence stderr, and treat any failure (no remote, no notes, -//! not a repo) as "not found" so the caller falls through to another -//! detection path. These two functions are that shared plumbing. - -use std::process::Command; -use std::process::Stdio; - -/// Read the engine's merge-queue note at `notes_ref` for `rev` -/// (`git notes --ref= show `) and return its full -/// payload as a JSON value. `notes_ref` may be the short -/// `mergify/` form or a fully-qualified -/// `refs/notes/mergify/` — git accepts both. -/// -/// The note body is the engine's `TrainInfo` YAML dump. We parse it -/// into a generic value rather than a fixed struct so the whole payload -/// is preserved — every field, including any the engine adds later. -/// Both `queue_info` (which prints the whole note) and `git_refs` -/// (which pulls `checking_base_sha` and the batch scopes out of it) -/// read it through here. -/// -/// Returns `None` when there's no note at `rev`, or the body isn't a -/// YAML mapping. -#[must_use] -pub(crate) fn read_note(notes_ref: &str, rev: &str) -> Option { - let content = capture(&["notes", &format!("--ref={notes_ref}"), "show", rev])?; - parse_yaml_mapping(&content) -} - -/// Parse an engine merge-queue YAML payload into a JSON value, keeping -/// every field. Shared by the git-note reader ([`read_note`]) and the -/// MQ PR-body reader (`queue_metadata::parse_yaml_block`): the engine -/// writes the *same* payload to both, so both must accept exactly the -/// same shapes. Restricted to mappings — a bare scalar or sequence -/// isn't merge-queue metadata and would make the JSON output -/// meaningless. -pub(crate) fn parse_yaml_mapping(content: &str) -> Option { - let value: serde_json::Value = serde_yaml_ng::from_str(content).ok()?; - value.is_object().then_some(value) -} - -/// Run a git subcommand for its exit status, discarding all output. -/// Returns `true` only on a clean exit. -#[must_use] -pub(crate) fn succeeds(args: &[&str]) -> bool { - Command::new("git") - .args(args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok_and(|s| s.success()) -} - -/// Run a git subcommand and capture stdout as UTF-8. Returns `None` on -/// any failure: the process couldn't spawn, exited non-zero, or wrote -/// non-UTF-8. -#[must_use] -pub(crate) fn capture(args: &[&str]) -> Option { - let output = Command::new("git") - .args(args) - .stderr(Stdio::null()) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_yaml_mapping_keeps_whole_payload() { - // The note body is the engine's full TrainInfo dump. Every - // field must survive into the value — including top-level and - // per-PR `scopes`, which a fixed struct would have dropped. - let body = "\ -scopes: - - backend -pull_requests: - - number: 7 - scopes: - - backend -previous_failed_batches: - - draft_pr_number: 42 - checked_pull_requests: - - 7 -checking_base_sha: cafef00d -"; - let note = parse_yaml_mapping(body).expect("engine payload should parse"); - assert_eq!(note["checking_base_sha"], "cafef00d"); - assert_eq!(note["scopes"][0], "backend"); - assert_eq!(note["pull_requests"][0]["number"], 7); - assert_eq!(note["pull_requests"][0]["scopes"][0], "backend"); - assert_eq!(note["previous_failed_batches"][0]["draft_pr_number"], 42); - } - - #[test] - fn parse_yaml_mapping_rejects_non_mapping() { - // A bare scalar or sequence isn't a merge-queue payload. - assert!(parse_yaml_mapping("just a scalar\n").is_none()); - assert!(parse_yaml_mapping("- a\n- b\n").is_none()); - } -} diff --git a/crates/mergify-ci/src/git_refs.rs b/crates/mergify-ci/src/git_refs.rs deleted file mode 100644 index 82388468..00000000 --- a/crates/mergify-ci/src/git_refs.rs +++ /dev/null @@ -1,1219 +0,0 @@ -//! `mergify ci git-refs` — print the base/head git references for -//! the current build. -//! -//! Detection order (matches Python): -//! -//! 1. Buildkite env (`BUILDKITE=true`) — also consults the engine's -//! `refs/notes/mergify/` namespace when the branch is -//! known, to override the target branch with the MQ checking -//! base. -//! 2. GitHub event payload — `pull_request`/`push` events with -//! various fallbacks (git note, MQ PR body, base SHA, default -//! branch). -//! 3. Plain `HEAD^..HEAD` when no event is available. -//! -//! Output formats: -//! -//! - `text` (default): `Base: ` and `Head: ` on two lines. -//! - `shell`: `MERGIFY_GIT_REFS_{BASE,HEAD,SOURCE}=...` lines, each -//! single-quoted via `shlex`-style quoting so the caller can `eval` -//! them. -//! - `json`: one JSON object on a single line. -//! -//! Side-effects: when `$GITHUB_OUTPUT` is set the command appends -//! the `base` and `head` step outputs through the crate's -//! `github_output` module, which writes them in the runner's heredoc -//! form so no value can declare an output of its own. When -//! `BUILDKITE=true` it invokes `buildkite-agent meta-data set` for -//! base/head/source. - -use mergify_core::env; -use std::collections::BTreeSet; -use std::io::Write; -use std::process::Command; - -use mergify_core::CliError; -use mergify_core::Output; -use serde::Serialize; - -use crate::github_event::GitHubEvent; -use crate::github_event::PULL_REQUEST_EVENTS; -use crate::github_event::load as load_event; -use crate::queue_metadata::extract_from_event; - -const BUILDKITE_BASE_METADATA_KEY: &str = "mergify-ci.base"; -const BUILDKITE_HEAD_METADATA_KEY: &str = "mergify-ci.head"; -const BUILDKITE_SOURCE_METADATA_KEY: &str = "mergify-ci.source"; - -/// Provenance tag for the detected references. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReferencesSource { - Manual, - MergeQueue, - FallbackLastCommit, - GithubEventOther, - GithubEventPullRequest, - GithubEventPush, - BuildkitePullRequest, -} - -impl ReferencesSource { - /// Wire-format string for the source provenance. Used in - /// emitted JSON / shell / markdown across `git-refs` and - /// `scopes`, so the names are part of the CLI's public - /// contract. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Manual => "manual", - Self::MergeQueue => "merge_queue", - Self::FallbackLastCommit => "fallback_last_commit", - Self::GithubEventOther => "github_event_other", - Self::GithubEventPullRequest => "github_event_pull_request", - Self::GithubEventPush => "github_event_push", - Self::BuildkitePullRequest => "buildkite_pull_request", - } - } -} - -#[derive(Debug, Clone)] -pub struct References { - pub base: Option, - pub head: String, - pub source: ReferencesSource, - /// The scopes the merge queue decided for this batch, read off - /// the engine's git note: see [`batch_scopes`]. `None` everywhere - /// else, the pull request body included, which whoever opens the - /// pull request writes. - pub batch_scopes: Option, -} - -/// What the merge queue decided a batch covers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BatchScopes { - pub scopes: BTreeSet, - /// The batch is a merge-queue barrier: it impacts every scope, - /// whatever `scopes` names. - pub all_scopes: bool, -} - -/// Trait-object-compatible hook for reading the engine's merge-queue -/// git note for `(branch, head_sha)`, as its whole payload. -/// -/// The real implementation shells out to `git`; tests inject a stub -/// so detection can exercise the note-driven branches without -/// touching a real repository. -pub type NotesReader<'a> = &'a dyn Fn(&str, &str) -> Option; - -#[derive(Serialize)] -struct JsonOutput<'a> { - base: Option<&'a str>, - head: &'a str, - source: &'a str, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Format { - Text, - Shell, - Json, -} - -impl Format { - /// Clap value-parser for `--format`. - /// - /// # Errors - /// - /// Returns a message when `value` is not one of `text`, `shell`, - /// or `json`. - pub fn parse(value: &str) -> Result { - match value { - "text" => Ok(Self::Text), - "shell" => Ok(Self::Shell), - "json" => Ok(Self::Json), - other => Err(format!( - "invalid format {other:?} (expected text, shell, or json)" - )), - } - } -} - -pub struct GitRefsOptions { - pub format: Format, -} - -/// Run the `ci git-refs` command. -pub fn run(opts: &GitRefsOptions, output: &mut dyn Output) -> Result<(), CliError> { - let notes_reader: NotesReader = &real_notes_reader; - let refs = detect(output, notes_reader)?; - emit(&refs, opts.format, output)?; - write_github_output(&refs)?; - write_buildkite_metadata(&refs)?; - Ok(()) -} - -/// Detect base/head references using the current environment. -/// -/// `notes_reader` is injected so tests can bypass the git -/// subprocess. Production callers pass [`real_notes_reader`]. -/// -/// # Errors -/// -/// Returns `CliError::Generic` when the event is a pull-request or -/// push event but no base SHA can be derived — matches Python's -/// `BaseNotFoundError`. -pub fn detect( - output: &mut dyn Output, - notes_reader: NotesReader<'_>, -) -> Result { - if env::var("BUILDKITE").as_deref() == Some("true") - && let Some(refs) = detect_from_buildkite(notes_reader) - { - return Ok(refs); - } - - let Some((event_name, event)) = load_event() else { - return Ok(References { - base: Some("HEAD^".to_string()), - head: "HEAD".to_string(), - source: ReferencesSource::FallbackLastCommit, - batch_scopes: None, - }); - }; - - if PULL_REQUEST_EVENTS.contains(&event_name.as_str()) { - if let Some(refs) = detect_from_pull_request_event(&event, output, notes_reader)? { - return Ok(refs); - } - } else if event_name == "push" { - if let Some(refs) = detect_from_push_event(&event) { - return Ok(refs); - } - } else { - return Ok(References { - base: None, - head: "HEAD".to_string(), - source: ReferencesSource::GithubEventOther, - batch_scopes: None, - }); - } - - Err(CliError::Generic( - "Could not detect base SHA. Provide GITHUB_EVENT_NAME / GITHUB_EVENT_PATH.".to_string(), - )) -} - -fn detect_from_buildkite(notes_reader: NotesReader<'_>) -> Option { - let pr = env::var("BUILDKITE_PULL_REQUEST")?; - if pr.is_empty() || pr == "false" { - return None; - } - let commit = env::var_non_empty("BUILDKITE_COMMIT").unwrap_or_else(|| "HEAD".to_string()); - if let Some(branch) = env::var_non_empty("BUILDKITE_BRANCH") - && let Some(note) = notes_reader(&branch, &commit) - && let Some(base) = checking_base_sha(¬e) - { - return Some(References { - base: Some(base), - head: commit, - source: ReferencesSource::MergeQueue, - batch_scopes: batch_scopes(¬e), - }); - } - let base_branch = env::var_non_empty("BUILDKITE_PULL_REQUEST_BASE_BRANCH")?; - Some(References { - base: Some(base_branch), - head: commit, - source: ReferencesSource::BuildkitePullRequest, - batch_scopes: None, - }) -} - -fn detect_from_pull_request_event( - event: &GitHubEvent, - output: &mut dyn Output, - notes_reader: NotesReader<'_>, -) -> std::io::Result> { - let head = event - .pull_request - .as_ref() - .and_then(|pr| pr.head.as_ref()) - .map_or_else(|| "HEAD".to_string(), |r| r.sha.clone()); - - // A fork can serve the note too, when the workflow checks it out - // as `origin`, so the note needs the same admission as the body. - if let Some(pr) = &event.pull_request - && pr.is_from_base_repository() - && let Some(head_ref) = &pr.head - && let Some(branch) = head_ref.r#ref.as_deref() - && let Some(note) = notes_reader(branch, &head_ref.sha) - && let Some(base) = checking_base_sha(¬e) - { - return Ok(Some(References { - base: Some(base), - head, - source: ReferencesSource::MergeQueue, - batch_scopes: batch_scopes(¬e), - })); - } - - if let Some(meta) = extract_from_event(event, output)? { - // Anyone who can open a pull request from a branch of the - // repository writes this body, so the value only becomes a base - // once it looks like one. Either way - // a payload we can't use is not metadata: fall through to the - // PR base below, which is the *wrong* base for a genuine batch - // build, so warn rather than let it be diagnosed silently. - match checking_base_sha(&meta) { - Some(base) if is_object_name(&base) => { - return Ok(Some(References { - base: Some(base), - head, - source: ReferencesSource::MergeQueue, - batch_scopes: None, - })); - } - Some(rejected) => output.status(&format!( - "WARNING: MQ pull request checking_base_sha {} is not a git object name, skipping metadata extraction", - elided(&rejected), - ))?, - // No such key — the shape a future engine rename produces. - None => output.status( - "WARNING: MQ pull request metadata without checking_base_sha, skipping metadata extraction", - )?, - } - } - - if let Some(pr) = &event.pull_request - && let Some(base) = &pr.base - { - return Ok(Some(References { - base: Some(base.sha.clone()), - head, - source: ReferencesSource::GithubEventPullRequest, - batch_scopes: None, - })); - } - - if let Some(repo) = &event.repository - && let Some(default_branch) = &repo.default_branch - { - return Ok(Some(References { - base: Some(default_branch.clone()), - head, - source: ReferencesSource::GithubEventPullRequest, - batch_scopes: None, - })); - } - - Ok(None) -} - -fn detect_from_push_event(event: &GitHubEvent) -> Option { - let head = event - .after - .clone() - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "HEAD".to_string()); - - if let Some(before) = event.before.as_deref().filter(|s| !s.is_empty()) { - return Some(References { - base: Some(before.to_string()), - head, - source: ReferencesSource::GithubEventPush, - batch_scopes: None, - }); - } - - let default_branch = event - .repository - .as_ref() - .and_then(|r| r.default_branch.clone())?; - Some(References { - base: Some(default_branch), - head: "HEAD".to_string(), - source: ReferencesSource::GithubEventPush, - batch_scopes: None, - }) -} - -/// Production implementation of [`NotesReader`]. Shells out to -/// `git fetch` + `git notes show` and swallows any failure as `None` -/// so callers can transparently fall through to other detection -/// paths. A note that lacks `checking_base_sha` falls through the -/// same way. -#[must_use] -pub fn real_notes_reader(branch: &str, head_sha: &str) -> Option { - let notes_ref_short = format!("mergify/{branch}"); - let notes_ref = format!("refs/notes/{notes_ref_short}"); - - if !crate::git::succeeds(&[ - "fetch", - "--no-tags", - "--quiet", - "origin", - &format!("+{notes_ref}:{notes_ref}"), - ]) { - return None; - } - - crate::git::read_note(¬es_ref_short, head_sha) -} - -/// Whether `value` is a full git object name: 40 lowercase hex -/// digits (SHA-1) or 64 (SHA-256). -/// -/// The gate this backs is on the *pull request body* payload alone, -/// and that asymmetry is the point. `queue_metadata::extract_from_event` -/// admits a body on the title prefix `merge queue: ` from a branch of -/// the base repository, so anyone who can open a pull request from one -/// writes the value; the git -/// note comes from the engine, over a push to `origin`. A value -/// admitted here leaves the CLI as `base`: into `$GITHUB_OUTPUT` and -/// the `--format=shell` eval for the caller's workflow, into -/// `ci scopes`' git invocations, and onto stderr, which GitHub -/// Actions also scans for `::` workflow commands. Something like -/// `--output=/tmp/x` is read as an *option* by whichever of those -/// gets it first (MRGFY-8845). -/// -/// Gating the note as well would be worse, not better: a note the -/// check rejected would fall through to the body path, handing the -/// untrusted payload the precedence the note is supposed to hold, and -/// with no warning since `real_notes_reader` has no `Output`. -/// -/// Full object names only, no abbreviations. The engine types the -/// field `github_types.SHAType` and writes a full SHA, and -/// `scopes_detect::changed_files::is_sha` routes only full SHAs as -/// revisions — an abbreviated one is fetched as a branch name and -/// fails the run, so accepting it would buy nothing. -fn is_object_name(value: &str) -> bool { - matches!(value.len(), 40 | 64) - && value - .bytes() - .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) -} - -/// Pull `checking_base_sha` out of an engine merge-queue payload — -/// either the git note ([`real_notes_reader`]) or the YAML block in -/// the MQ draft PR body (`queue_metadata`), which carry the same dump. -/// Both read it through here so neither is coupled to the payload's -/// other keys, which the engine renames independently. -/// -/// The value is returned as the payload spells it. The body path -/// holds it to [`is_object_name`] before use; see there for why the -/// note path does not. -fn checking_base_sha(note: &serde_json::Value) -> Option { - match note.get("checking_base_sha")? { - serde_json::Value::String(s) => Some(s.clone()), - _ => None, - } -} - -/// The scopes the merge queue decided for the batch: the note's -/// `scopes` (the batch's own set) unioned with every -/// `pull_requests[].scopes`, so a pull whose scopes grew after the -/// batch was composed is still tested for them. -/// -/// `all_scopes` is the note's barrier flag, read strictly: anything -/// but `true` is not a barrier, which is what a note that does not -/// carry it means. -/// -/// `None` when `scopes` is missing, which is what an engine predating -/// the field writes, or when it or a pull's `scopes` is not a list -/// of strings: the caller then diffs files as it always did, rather -/// than test a batch without a pull's scopes. A present but empty list is a -/// batch that touches no scope, and is returned as such. -fn batch_scopes(note: &serde_json::Value) -> Option { - fn strings(value: &serde_json::Value) -> Option> { - value - .as_array()? - .iter() - .map(|v| v.as_str().map(ToString::to_string)) - .collect() - } - let mut scopes: BTreeSet = strings(note.get("scopes")?)?.into_iter().collect(); - for pull in note - .get("pull_requests") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - if let Some(pull_scopes) = pull.get("scopes") { - scopes.extend(strings(pull_scopes)?); - } - } - Some(BatchScopes { - scopes, - all_scopes: note.get("all_scopes") == Some(&serde_json::Value::Bool(true)), - }) -} - -/// Render an untrusted scalar for a warning: `{:?}`-escaped, and cut -/// to a length a log can hold. -/// -/// Both halves are load-bearing. `output.status` writes to stderr, -/// which GitHub Actions scans for `::` workflow commands, and `{:?}` -/// escapes the newline that would be needed to start one. Nothing -/// upstream bounds the scalar — `queue_metadata::parse_yaml_block` -/// joins the whole fenced block — and `{:?}` expands a control -/// character to six chars, so the cut keeps a rejected value from -/// filling the log. -fn elided(value: &str) -> String { - const LIMIT: usize = 64; - let head: String = value.chars().take(LIMIT).collect(); - if head.len() == value.len() { - format!("{head:?}") - } else { - format!("{head:?} ({} bytes total)", value.len()) - } -} - -fn emit(refs: &References, format: Format, output: &mut dyn Output) -> std::io::Result<()> { - match format { - Format::Text => output.emit(&(), &mut |w: &mut dyn Write| { - // A missing base renders as the literal "None" to match - // Python's f-string (`f"Base: {ref.base}"` with - // ref.base = None). The Shell arm keeps the empty string - // since that's a shell variable value, not display text. - writeln!(w, "Base: {}", refs.base.as_deref().unwrap_or("None"))?; - writeln!(w, "Head: {}", refs.head) - }), - Format::Shell => output.emit(&(), &mut |w: &mut dyn Write| { - writeln!( - w, - "MERGIFY_GIT_REFS_BASE={}", - shell_quote(refs.base.as_deref().unwrap_or("")) - )?; - writeln!(w, "MERGIFY_GIT_REFS_HEAD={}", shell_quote(&refs.head))?; - writeln!( - w, - "MERGIFY_GIT_REFS_SOURCE={}", - shell_quote(refs.source.as_str()) - ) - }), - Format::Json => { - let payload = JsonOutput { - base: refs.base.as_deref(), - head: &refs.head, - source: refs.source.as_str(), - }; - output.emit(&payload, &mut |w: &mut dyn Write| { - let rendered = serde_json::to_string(&payload) - .map_err(|e| std::io::Error::other(e.to_string()))?; - writeln!(w, "{rendered}") - }) - } - } -} - -/// Best-effort POSIX shell quoting. Mirrors `shlex.quote`: empty and -/// "safe" strings stay bare, everything else is single-quoted with -/// embedded `'` rewritten to `'"'"'`. -fn shell_quote(value: &str) -> String { - if value.is_empty() { - return "''".to_string(); - } - let safe = value.chars().all(|c| { - c.is_ascii_alphanumeric() - || matches!(c, '@' | '%' | '+' | '=' | ':' | ',' | '.' | '/' | '-' | '_') - }); - if safe { - return value.to_string(); - } - let escaped = value.replace('\'', "'\"'\"'"); - format!("'{escaped}'") -} - -fn write_github_output(refs: &References) -> Result<(), CliError> { - crate::github_output::append(&[ - ("base", refs.base.as_deref().unwrap_or("")), - ("head", &refs.head), - ]) -} - -fn write_buildkite_metadata(refs: &References) -> std::io::Result<()> { - if env::var("BUILDKITE").as_deref() != Some("true") { - return Ok(()); - } - if let Some(base) = refs.base.as_deref() { - buildkite_meta_data_set(BUILDKITE_BASE_METADATA_KEY, base)?; - } - buildkite_meta_data_set(BUILDKITE_HEAD_METADATA_KEY, &refs.head)?; - buildkite_meta_data_set(BUILDKITE_SOURCE_METADATA_KEY, refs.source.as_str())?; - Ok(()) -} - -fn buildkite_meta_data_set(key: &str, value: &str) -> std::io::Result<()> { - let status = Command::new("buildkite-agent") - .args(["meta-data", "set", key, value]) - .status()?; - if !status.success() { - return Err(std::io::Error::other(format!( - "buildkite-agent meta-data set {key} exited with status {status}" - ))); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use mergify_test_support::Captured; - use tempfile::TempDir; - - use super::*; - - /// Full object names, since that is now all `is_object_name` - /// admits and the engine only ever writes one. - const MQ_BASE: &str = "cafef00dcafef00dcafef00dcafef00dcafef00d"; - const NOTE_BASE: &str = "0badc0de0badc0de0badc0de0badc0de0badc0de"; - - fn no_notes(_branch: &str, _sha: &str) -> Option { - None - } - - #[test] - fn checking_base_sha_reads_the_scalar_as_the_payload_spells_it() { - use serde_json::json; - assert_eq!( - checking_base_sha(&json!({"checking_base_sha": MQ_BASE, "scopes": ["x"]})), - Some(MQ_BASE.to_string()), - ); - // Read verbatim: the shape check is `is_object_name`, applied - // by the pull request body path and not by the note path. - assert_eq!( - checking_base_sha(&json!({"checking_base_sha": "--output=/tmp/x"})), - Some("--output=/tmp/x".to_string()), - ); - // No field, or one the engine nulled because the batch has no - // checking base → None, so detection falls through. - assert_eq!(checking_base_sha(&json!({"pull_requests": []})), None); - assert_eq!(checking_base_sha(&json!({"checking_base_sha": null})), None); - } - - #[test] - fn is_object_name_admits_only_full_hex_object_names() { - // Anyone who can open a pull request from a branch of the - // repository writes the body this gates, and the value ends up as `base` in the caller's - // workflow. A leading `-` above all: every consumer of `base` - // reads it as an option (MRGFY-8845). - for value in [ - "--output=/home/runner/.gitconfig", - "-x", - "main", - "HEAD^", - "../etc/passwd", - &format!("{MQ_BASE}\nbase=pwned"), - &format!("{MQ_BASE} pwned"), - // Abbreviated: `changed_files::is_sha` would fetch it as a - // branch name. Off by one either way. Uppercase, which the - // engine never writes. Empty. - "cafef00d", - &"a".repeat(39), - &"a".repeat(41), - &MQ_BASE.to_uppercase(), - "", - ] { - assert!(!is_object_name(value), "should reject {value:?}"); - } - // SHA-1 and the SHA-256 spelling a future engine would write. - assert!(is_object_name(MQ_BASE)); - assert!(is_object_name(&"b2".repeat(32))); - } - - #[test] - fn elided_escapes_and_bounds_an_untrusted_scalar() { - // A newline must not survive as one, or the warning could - // start a `::` workflow command on GitHub Actions. - assert_eq!(elided("a\nb"), r#""a\nb""#); - // Nothing upstream bounds the scalar, so a long one is cut and - // says so rather than filling the log. - let rendered = elided(&"x".repeat(300)); - assert!(rendered.ends_with(" (300 bytes total)"), "got: {rendered}"); - assert!(rendered.len() < 100, "got: {rendered}"); - } - - fn write_event(dir: &TempDir, payload: &serde_json::Value) -> PathBuf { - let path = dir.path().join("event.json"); - std::fs::write(&path, serde_json::to_vec(payload).unwrap()).unwrap(); - path - } - - #[test] - fn falls_back_to_head_pair_when_no_event() { - let mut cap = Captured::human(); - // An empty overlay is the empty environment: no provider - // variable is visible, whatever the host exports. - let refs = env::testing::with_no_vars(|| detect(&mut cap.output, &no_notes).unwrap()); - assert_eq!(refs.base.as_deref(), Some("HEAD^")); - assert_eq!(refs.head, "HEAD"); - assert_eq!(refs.source, ReferencesSource::FallbackLastCommit); - } - - #[test] - fn detects_from_pull_request_base() { - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "base": {"sha": "base-sha"}, - "head": {"sha": "head-sha", "ref": "feat/x"}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("base-sha")); - assert_eq!(refs.head, "head-sha"); - assert_eq!(refs.source, ReferencesSource::GithubEventPullRequest); - } - - #[test] - fn detects_from_push_before_sha() { - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({"before": "old-sha", "after": "new-sha"}), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("push")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("old-sha")); - assert_eq!(refs.head, "new-sha"); - assert_eq!(refs.source, ReferencesSource::GithubEventPush); - } - - #[test] - fn detects_mq_from_pr_body_yaml() { - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": format!("prelude\n```yaml\nchecking_base_sha: {MQ_BASE}\n```"), - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some(MQ_BASE)); - assert_eq!(refs.head, "mq-head"); - assert_eq!(refs.source, ReferencesSource::MergeQueue); - } - - #[test] - fn mq_title_from_a_fork_is_not_a_merge_queue_draft() { - // MRGFY-8854, the ticket's reproduction: a fork's pull request - // titled `merge queue: ` whose body names its own head as - // `checking_base_sha`. Admitted, the diff is empty, every scope - // reads false and `source` turns on the merge-queue scope. A - // workflow that checks the fork out as `origin` fetches the - // fork's note, which says the same. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: attacker batch", - "body": format!("```yaml\nchecking_base_sha: {MQ_BASE}\n```"), - "head": {"sha": MQ_BASE, "ref": "attacker-fork-branch", "repo": {"id": 2}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let forged_note = |_: &str, _: &str| { - Some(serde_json::json!({"checking_base_sha": MQ_BASE, "scopes": []})) - }; - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request_target")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &forged_note).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("pr-base")); - assert_eq!(refs.source, ReferencesSource::GithubEventPullRequest); - assert!(refs.batch_scopes.is_none()); - } - - #[test] - fn mq_body_with_post_contract_batch_still_yields_base() { - // The ticket's exact failure mode (MRGFY-8104): a batch body - // carries a non-empty `previous_failed_batches`, and a - // post-contract engine emits `batch_pr_number` with no - // `draft_pr_number`. The old rigid struct required - // `draft_pr_number`, so the whole document failed to parse and - // `checking_base_sha` was silently lost, dropping git-refs to - // the wrong-base `GithubEventPullRequest` fallthrough. The - // generic parse must keep the base and the MergeQueue source. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": format!("```yaml\nchecking_base_sha: {MQ_BASE}\nprevious_failed_batches:\n - batch_pr_number: 42\n checked_pull_requests:\n - 7\n```"), - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "wrong-base", "repo": {"id": 1}}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some(MQ_BASE)); - assert_eq!(refs.source, ReferencesSource::MergeQueue); - } - - #[test] - fn mq_body_metadata_without_base_warns_and_falls_through() { - // A valid metadata block that carries no checking_base_sha - // (the shape a future engine key rename would produce) must not - // silently pick the wrong base: git-refs falls through to the - // PR base and warns so the operator sees why. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": "```yaml\npull_requests:\n - number: 7\n```", - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("pr-base")); - assert_eq!(refs.source, ReferencesSource::GithubEventPullRequest); - assert!( - cap.stderr().contains("without checking_base_sha"), - "got: {:?}", - cap.stderr() - ); - } - - #[test] - fn mq_body_with_option_like_base_warns_and_falls_through() { - // MRGFY-8845: `extract_from_event` admitted any pull request - // whose title starts with "merge queue: ", so the reporter's - // fork PR put `--output=` in the body and git-refs - // emitted it as `base`, where the workflow's next git call - // read it as an option. A value that is not an object name is - // not metadata: fall through to the PR base and say so. Forks - // are refused since MRGFY-8854, but a branch of the repository - // still writes the body. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": "```yaml\nchecking_base_sha: --output=/home/runner/.gitconfig\n```", - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("pr-base")); - assert_eq!(refs.source, ReferencesSource::GithubEventPullRequest); - assert!( - cap.stderr().contains("is not a git object name"), - "got: {:?}", - cap.stderr() - ); - } - - #[test] - fn rejected_checking_base_sha_cannot_inject_a_workflow_command() { - // The warning echoes the rejected value, and GitHub Actions - // scans a step's stderr for `::` workflow commands — so the - // escaping matters as much as the rejection. A newline in the - // value must not start a line of its own. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": "```yaml\nchecking_base_sha: \"cafef00d\\n::error::pwned\"\n```", - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("pr-base")); - let stderr = cap.stderr(); - assert!( - !stderr.lines().any(|l| l.starts_with("::")), - "got: {stderr:?}", - ); - assert!( - stderr.contains(r"cafef00d\n::error::pwned"), - "got: {stderr:?}", - ); - } - - #[test] - fn mq_notes_beat_body_yaml() { - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": format!("```yaml\nchecking_base_sha: {MQ_BASE}\n```"), - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let note_reader = |branch: &str, sha: &str| { - if branch == "mq/main/0" && sha == "mq-head" { - Some(serde_json::json!({"checking_base_sha": NOTE_BASE})) - } else { - None - } - }; - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, ¬e_reader).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some(NOTE_BASE)); - } - - #[test] - fn batch_scopes_unions_the_batch_with_every_pull() { - use serde_json::json; - let set = |v: &[&str]| BatchScopes { - scopes: v.iter().map(|s| (*s).to_string()).collect(), - all_scopes: false, - }; - assert_eq!( - batch_scopes(&json!({ - "scopes": ["a"], - "pull_requests": [{"number": 1, "scopes": ["a", "b"]}, {"number": 2}], - })), - Some(set(&["a", "b"])), - ); - // A batch touching no scope is an answer, not a missing one. - assert_eq!( - batch_scopes(&json!({"scopes": [], "pull_requests": []})), - Some(set(&[])) - ); - assert_eq!( - batch_scopes(&json!({"scopes": [], "all_scopes": true})), - Some(BatchScopes { - scopes: BTreeSet::new(), - all_scopes: true - }) - ); - assert_eq!( - batch_scopes(&json!({"scopes": [], "all_scopes": "true"})), - Some(set(&[])) - ); - // An engine predating the field, or a payload we can't read: - // diff files as before. - assert_eq!(batch_scopes(&json!({"pull_requests": []})), None); - assert_eq!(batch_scopes(&json!({"scopes": "a"})), None); - assert_eq!(batch_scopes(&json!({"scopes": ["a", 1]})), None); - assert_eq!( - batch_scopes(&json!({"scopes": ["a"], "pull_requests": [{"scopes": [1e10]}]})), - None - ); - } - - #[test] - fn mq_note_carries_batch_scopes_but_body_does_not() { - // The body is written by whoever opens the pull request, so - // only the engine's note decides scopes. - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({ - "pull_request": { - "title": "merge queue: batch", - "body": format!("```yaml\nchecking_base_sha: {MQ_BASE}\nscopes: [body]\n```"), - "head": {"sha": "mq-head", "ref": "mq/main/0", "repo": {"id": 1}}, - "base": {"sha": "pr-base", "repo": {"id": 1}}, - }, - }), - ); - let note_reader = |_: &str, _: &str| { - Some(serde_json::json!({ - "checking_base_sha": NOTE_BASE, - "scopes": ["a"], - "pull_requests": [{"number": 1, "scopes": ["a"]}, {"number": 2, "scopes": ["b"]}], - })) - }; - let vars = [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ]; - let mut cap = Captured::human(); - let refs = env::testing::with_vars(vars, || detect(&mut cap.output, ¬e_reader).unwrap()); - assert_eq!( - refs.batch_scopes, - Some(BatchScopes { - scopes: ["a".to_string(), "b".to_string()].into(), - all_scopes: false, - }) - ); - let refs = env::testing::with_vars(vars, || detect(&mut cap.output, &no_notes).unwrap()); - assert_eq!(refs.source, ReferencesSource::MergeQueue); - assert_eq!(refs.batch_scopes, None); - } - - #[test] - fn buildkite_mq_note_carries_batch_scopes() { - let note_reader = |branch: &str, sha: &str| { - (branch == "mq/main/0" && sha == "mq-head") - .then(|| serde_json::json!({"checking_base_sha": NOTE_BASE, "scopes": ["a"]})) - }; - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_PULL_REQUEST", Some("7")), - ("BUILDKITE_COMMIT", Some("mq-head")), - ("BUILDKITE_BRANCH", Some("mq/main/0")), - ], - || detect(&mut cap.output, ¬e_reader).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some(NOTE_BASE)); - assert_eq!( - refs.batch_scopes, - Some(BatchScopes { - scopes: ["a".to_string()].into(), - all_scopes: false, - }) - ); - } - - #[test] - fn errors_when_pr_event_missing_base() { - let dir = tempfile::tempdir().unwrap(); - let path = write_event( - &dir, - &serde_json::json!({"pull_request": {"head": {"sha": "h"}}}), - ); - let mut cap = Captured::human(); - let err = env::testing::with_vars( - [ - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(path.to_str().unwrap())), - ("BUILDKITE", None), - ], - || detect(&mut cap.output, &no_notes).unwrap_err(), - ); - assert!(err.to_string().contains("Could not detect base SHA")); - } - - #[test] - fn detects_buildkite_pull_request() { - let mut cap = Captured::human(); - let refs = env::testing::with_vars( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_PULL_REQUEST", Some("42")), - ("BUILDKITE_COMMIT", Some("sha-head")), - ("BUILDKITE_BRANCH", Some("feat/x")), - ("BUILDKITE_PULL_REQUEST_BASE_BRANCH", Some("main")), - ("GITHUB_EVENT_NAME", None), - ("GITHUB_EVENT_PATH", None), - ], - || detect(&mut cap.output, &no_notes).unwrap(), - ); - assert_eq!(refs.base.as_deref(), Some("main")); - assert_eq!(refs.head, "sha-head"); - assert_eq!(refs.source, ReferencesSource::BuildkitePullRequest); - } - - #[test] - fn shell_quote_basic_cases() { - assert_eq!(shell_quote(""), "''"); - assert_eq!(shell_quote("feat/x"), "feat/x"); - assert_eq!(shell_quote("has space"), "'has space'"); - assert_eq!(shell_quote("bob's"), "'bob'\"'\"'s'"); - } - - #[test] - fn emits_text_format() { - let refs = References { - base: Some("b".into()), - head: "h".into(), - source: ReferencesSource::GithubEventPush, - batch_scopes: None, - }; - let mut cap = Captured::human(); - emit(&refs, Format::Text, &mut cap.output).unwrap(); - let stdout = cap.stdout(); - assert_eq!(stdout, "Base: b\nHead: h\n"); - } - - #[test] - fn emits_text_format_with_none_base() { - // A missing base (e.g. workflow_dispatch / schedule events) - // renders the literal "None", matching Python's f-string. - let refs = References { - base: None, - head: "HEAD".into(), - source: ReferencesSource::GithubEventOther, - batch_scopes: None, - }; - let mut cap = Captured::human(); - emit(&refs, Format::Text, &mut cap.output).unwrap(); - let stdout = cap.stdout(); - assert_eq!(stdout, "Base: None\nHead: HEAD\n"); - } - - #[test] - fn emits_shell_format() { - let refs = References { - base: Some("main".into()), - head: "has space".into(), - source: ReferencesSource::MergeQueue, - batch_scopes: None, - }; - let mut cap = Captured::human(); - emit(&refs, Format::Shell, &mut cap.output).unwrap(); - let stdout = cap.stdout(); - assert!(stdout.contains("MERGIFY_GIT_REFS_BASE=main")); - assert!(stdout.contains("MERGIFY_GIT_REFS_HEAD='has space'")); - assert!(stdout.contains("MERGIFY_GIT_REFS_SOURCE=merge_queue")); - } - - #[test] - fn emits_json_format() { - let refs = References { - base: None, - head: "HEAD".into(), - source: ReferencesSource::GithubEventOther, - batch_scopes: None, - }; - let mut cap = Captured::human(); - emit(&refs, Format::Json, &mut cap.output).unwrap(); - let stdout = cap.stdout(); - assert_eq!( - stdout.trim_end(), - r#"{"base":null,"head":"HEAD","source":"github_event_other"}"# - ); - } - - #[test] - fn github_output_never_lets_base_declare_another_output() { - // `base` is the one reference that can come from a payload - // written by hand, so the step outputs go out in heredoc form - // rather than as `base=` lines a newline could split - // (MRGFY-8845). `checking_base_sha` validation already keeps - // this value out, so the newline here stands in for whatever - // reaches `base` next. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gha_output"); - let refs = References { - base: Some(format!("{MQ_BASE}\nevil=1")), - head: NOTE_BASE.into(), - source: ReferencesSource::MergeQueue, - batch_scopes: None, - }; - env::testing::with_var("GITHUB_OUTPUT", Some(path.to_str().unwrap()), || { - write_github_output(&refs).unwrap(); - }); - let written = std::fs::read_to_string(&path).unwrap(); - // Parse it the way the runner does, so the assertion is about - // the outputs the step declares rather than about the bytes. - let mut lines = written.lines(); - let mut outputs: Vec<(String, String)> = Vec::new(); - while let Some(line) = lines.next() { - let (name, delimiter) = line.split_once("<<").expect("heredoc form"); - let mut value = Vec::new(); - for body in lines.by_ref() { - if body == delimiter { - break; - } - value.push(body); - } - outputs.push((name.to_string(), value.join("\n"))); - } - assert_eq!( - outputs, - [ - ("base".to_string(), format!("{MQ_BASE}\nevil=1")), - ("head".to_string(), NOTE_BASE.to_string()), - ], - ); - } - - #[test] - fn format_parse_round_trips() { - assert!(matches!(Format::parse("text"), Ok(Format::Text))); - assert!(matches!(Format::parse("shell"), Ok(Format::Shell))); - assert!(matches!(Format::parse("json"), Ok(Format::Json))); - assert!(Format::parse("yaml").is_err()); - } -} diff --git a/crates/mergify-ci/src/github_event.rs b/crates/mergify-ci/src/github_event.rs deleted file mode 100644 index bca24fb7..00000000 --- a/crates/mergify-ci/src/github_event.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Deserialization of the GitHub Actions event payload. -//! -//! Mirrors the `pydantic` models in `mergify_cli.ci.github_event`. -//! All structs ignore unknown fields (`serde(default)` + no -//! `deny_unknown_fields` on purpose) so the payload's superset of -//! fields doesn't break us. - -use mergify_core::env; -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct GitRef { - pub sha: String, - #[serde(default)] - pub r#ref: Option, - /// `null` on a pull request's `head` once its fork is deleted. - #[serde(default)] - pub repo: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct RefRepository { - /// Optional so that a `repo` without it costs only the trust in - /// [`PullRequest::is_from_base_repository`], not the whole event. - #[serde(default)] - pub id: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct PullRequest { - #[serde(default)] - pub number: Option, - #[serde(default)] - pub title: Option, - #[serde(default)] - pub body: Option, - #[serde(default)] - pub base: Option, - #[serde(default)] - pub head: Option, -} - -impl PullRequest { - /// Whether the head branch lives in the base repository, which is - /// where the engine always opens its merge queue drafts. - /// - /// This is what lets the CLI trust merge queue metadata, never the - /// `merge queue: ` title, which the author picks (MRGFY-8854). It - /// rules out a fork, whose author writes the code, the body, and, - /// when the workflow checks the fork out as `origin`, the git note - /// too. It does not rule out a reader opening a pull request from a - /// branch someone else pushed: they write its title and body, but - /// not its code. The branch prefix and the author are no better: - /// `queue_branch_prefix` and `draft_bot_account` are configurable. - /// - /// `false` when either side has no repository id, including the - /// `null` head of a deleted fork. - #[must_use] - pub fn is_from_base_repository(&self) -> bool { - let repo_id = |r: &Option| r.as_ref()?.repo.as_ref()?.id; - matches!( - (repo_id(&self.head), repo_id(&self.base)), - (Some(head), Some(base)) if head == base - ) - } -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct Repository { - #[serde(default)] - pub default_branch: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -pub struct GitHubEvent { - #[serde(default)] - pub pull_request: Option, - #[serde(default)] - pub repository: Option, - #[serde(default)] - pub before: Option, - #[serde(default)] - pub after: Option, -} - -/// Events that carry a pull request in their payload. -pub const PULL_REQUEST_EVENTS: &[&str] = &[ - "pull_request", - "pull_request_review", - "pull_request_review_comment", - "pull_request_target", -]; - -/// Load the event payload from `GITHUB_EVENT_PATH`, keyed by -/// `GITHUB_EVENT_NAME`. -/// -/// Returns `None` when either env var is missing, the file does not -/// exist, or the JSON cannot be parsed — mirrors Python's -/// `GitHubEventNotFoundError` being converted to a fallback. -#[must_use] -pub fn load() -> Option<(String, GitHubEvent)> { - let event_name = env::var_non_empty("GITHUB_EVENT_NAME")?; - let event_path = env::var_non_empty("GITHUB_EVENT_PATH")?; - let path = PathBuf::from(event_path); - if !path.is_file() { - return None; - } - let raw = std::fs::read_to_string(&path).ok()?; - let event: GitHubEvent = serde_json::from_str(&raw).ok()?; - Some((event_name, event)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deserialize_minimal_event() { - let raw = r#"{"pull_request": {"number": 42}}"#; - let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); - assert_eq!(ev.pull_request.unwrap().number, Some(42)); - } - - #[test] - fn deserialize_ignores_unknown_fields() { - let raw = r#"{"pull_request": {"number": 7, "unknown": "x"}, "foo": 1}"#; - let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); - assert_eq!(ev.pull_request.unwrap().number, Some(7)); - } - - #[test] - fn is_from_base_repository_needs_both_ids_and_equal() { - let pr = |raw: &str| serde_json::from_str::(raw).unwrap(); - assert!(pr(r#"{"head": {"sha": "h", "repo": {"id": 1}}, "base": {"sha": "b", "repo": {"id": 1}}}"#).is_from_base_repository()); - for raw in [ - r#"{"head": {"sha": "h", "repo": {"id": 2}}, "base": {"sha": "b", "repo": {"id": 1}}}"#, - r#"{"head": {"sha": "h", "repo": null}, "base": {"sha": "b", "repo": {"id": 1}}}"#, - r#"{"head": {"sha": "h", "repo": {"id": 1}}, "base": {"sha": "b"}}"#, - r#"{"head": {"sha": "h"}, "base": {"sha": "b"}}"#, - r#"{"head": {"sha": "h", "repo": {}}, "base": {"sha": "b", "repo": {}}}"#, - ] { - assert!(!pr(raw).is_from_base_repository(), "admitted {raw}"); - } - } - - #[test] - fn deserialize_push_event_shape() { - let raw = r#"{"before": "a", "after": "b", "repository": {"default_branch": "main"}}"#; - let ev: GitHubEvent = serde_json::from_str(raw).unwrap(); - assert_eq!(ev.before.as_deref(), Some("a")); - assert_eq!(ev.after.as_deref(), Some("b")); - assert_eq!( - ev.repository.unwrap().default_branch.as_deref(), - Some("main") - ); - } -} diff --git a/crates/mergify-ci/src/github_output.rs b/crates/mergify-ci/src/github_output.rs deleted file mode 100644 index 9a9f725e..00000000 --- a/crates/mergify-ci/src/github_output.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Append step outputs to `$GITHUB_OUTPUT`. -//! -//! A GitHub Actions step publishes its outputs by appending them to -//! the file named by `$GITHUB_OUTPUT`. The obvious `name=value` line -//! has no escape: a newline inside `value` ends the assignment, and -//! every following line is parsed as another output — so whatever -//! chose the value also chooses how many outputs the step declares. -//! The runner's own answer is the heredoc form -//! -//! ```text -//! name< -//! value -//! ghadelimiter_<32 hex> -//! ``` -//! -//! which is what [`append`] always emits. The delimiter is drawn from -//! the OS RNG per output, so a value cannot contain it and no call -//! site has to reason about what its value may hold. GitHub parses -//! both forms into the same output value, so consuming workflows see -//! no difference. -//! -//! One writer in the crate does not go through here: -//! `junit_process::command::maybe_write_github_output` writes -//! `test_results_upload=` in the bare form. Its value is one -//! of three `&'static str` literals, and it is deliberately -//! best-effort ("reporting plumbing must never break the run") where -//! [`append`] propagates a `CliError`. Make that status dynamic and -//! it has to move here first. -//! -//! The heredoc protects the value half of the pair; the name half is -//! protected by its type. `&'static str` cannot hold a name derived -//! from a payload, and a name is the other place a newline (or an `=` -//! ahead of the `<<`) would let the runner read the block as -//! something else. - -use mergify_core::env; -use std::fmt::Write as _; -use std::fs::OpenOptions; -use std::io::Write as _; -use std::path::PathBuf; - -use mergify_core::CliError; - -/// Append each `(name, value)` pair to `$GITHUB_OUTPUT` as a step -/// output. No-op when the variable is unset or empty — i.e. anywhere -/// but a GitHub Actions runner. -pub(crate) fn append(outputs: &[(&'static str, &str)]) -> Result<(), CliError> { - let Some(path) = env::var_non_empty("GITHUB_OUTPUT") else { - return Ok(()); - }; - // Assembled first, then written once. Three `writeln!` calls on an - // unbuffered `File` are several `write_all` syscalls each, and a - // sequence cut in the middle leaves an unterminated heredoc, which - // fails the step outright ("Matching delimiter not found") — a - // worse outcome than the truncated line the `name=value` form - // would have left. - let mut block = String::new(); - for (name, value) in outputs { - let delimiter = format!("ghadelimiter_{}", random_delimiter_suffix()?); - // Writing to a String is infallible. - let _ = writeln!(block, "{name}<<{delimiter}\n{value}\n{delimiter}"); - } - OpenOptions::new() - .create(true) - .append(true) - .open(PathBuf::from(path))? - .write_all(block.as_bytes())?; - Ok(()) -} - -/// 16 random bytes rendered as 32 lowercase hex chars — enough -/// entropy to be unguessable inside one GitHub Actions step, which is -/// all the heredoc delimiter needs (it just has to be absent from the -/// value). `getrandom` reads the OS RNG directly; we don't need the -/// parsing/formatting plumbing `uuid` adds on top. -fn random_delimiter_suffix() -> Result { - let mut buf = [0u8; 16]; - getrandom::fill(&mut buf).map_err(|e| CliError::wrap("draw a $GITHUB_OUTPUT delimiter", e))?; - let mut hex = String::with_capacity(buf.len() * 2); - for b in buf { - // Writing to a String is infallible. - let _ = write!(hex, "{b:02x}"); - } - Ok(hex) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn append_is_a_noop_outside_github_actions() { - env::testing::with_var("GITHUB_OUTPUT", None::<&str>, || { - append(&[("k", "v")]).unwrap(); - }); - // An empty value is treated the same as unset: the runner - // exports `GITHUB_OUTPUT=` in some contexts, and an empty - // path is not openable. - env::testing::with_var("GITHUB_OUTPUT", Some(""), || { - append(&[("k", "v")]).unwrap(); - }); - } - - #[test] - fn append_wraps_every_output_in_its_own_heredoc() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gha_output"); - env::testing::with_var("GITHUB_OUTPUT", Some(path.to_str().unwrap()), || { - append(&[("base", "cafef00d"), ("head", "0badc0de")]).unwrap(); - }); - let written = std::fs::read_to_string(&path).unwrap(); - let lines: Vec<&str> = written.lines().collect(); - assert_eq!(lines.len(), 6, "got: {written:?}"); - assert!( - lines[0].starts_with("base< = written.lines().collect(); - let delimiter = &lines[0]["base<<".len()..]; - assert_eq!(lines.len(), 4, "got: {written:?}"); - assert_eq!(&lines[1..3], ["cafef00d", "evil=1"]); - assert_eq!(lines[3], delimiter); - } - - #[test] - fn append_keeps_earlier_content() { - // The runner accumulates every step's outputs in one file, so - // the write must append rather than truncate. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("gha_output"); - std::fs::write(&path, "earlier=1\n").unwrap(); - env::testing::with_var("GITHUB_OUTPUT", Some(path.to_str().unwrap()), || { - append(&[("base", "cafef00d")]).unwrap(); - }); - let written = std::fs::read_to_string(&path).unwrap(); - assert!(written.starts_with("earlier=1\n"), "got: {written:?}"); - } -} diff --git a/crates/mergify-ci/src/junit_process/command.rs b/crates/mergify-ci/src/junit_process/command.rs deleted file mode 100644 index 105ce2c3..00000000 --- a/crates/mergify-ci/src/junit_process/command.rs +++ /dev/null @@ -1,2198 +0,0 @@ -//! `mergify ci junit-process` orchestration. -//! -//! Glues the four `junit_process` modules together: parse `JUnit` -//! XML → check quarantine status → build OTLP spans (now tagged -//! with `cicd.test.quarantined`) → upload them, then render the -//! human-facing report the way Python's `process_junit_files` -//! does. Errors during quarantine or upload are *non-fatal* by -//! design — Mergify-side trouble must never break customer CI, so -//! the exit code is driven solely by the -//! failing-tests-not-quarantined count plus the silent-failure -//! detection. Upload failures are surfaced instead of swallowed -//! (issue #1571): the report calls them out, and on GitHub -//! Actions the run emits an `::error::`/`::warning::` annotation -//! plus a `test_results_upload` step output so workflows can -//! detect dead ingest programmatically. - -// The report builder appends formatted snippets to a single -// `String`. clippy's `format_push_string` lint suggests `write!` -// everywhere, which adds a `use std::fmt::Write` and an awkward -// `let _ = write!(…)` per line for no semantic improvement — -// `String::push_str(&format!(…))` is the readable form for this -// kind of templated text emission. -#![allow(clippy::format_push_string)] - -use std::path::{Path, PathBuf}; - -use mergify_core::auth; -use mergify_core::env; -use mergify_core::{CliError, ExitCode, Output}; - -use crate::detector; -use crate::junit_process::junit::{self, ParseResult, TestCase}; -use crate::junit_process::quarantine::{self, QuarantineFailed, QuarantineResult}; -use crate::junit_process::spans::{self, UploadMetadata}; -use crate::junit_process::split; -use crate::junit_process::upload; - -const SEPARATOR: &str = "══════════════════════════════════════════"; -const SEPARATOR_LIGHT: &str = "──────────────────────────────────────────"; - -/// CLI options for `mergify ci junit-process`. -pub struct JunitProcessOptions<'a> { - pub api_url: Option<&'a str>, - pub token: Option<&'a str>, - pub repository: Option<&'a str>, - pub test_framework: Option<&'a str>, - pub test_language: Option<&'a str>, - pub tests_target_branch: Option<&'a str>, - pub test_exit_code: Option, - /// Raw `files` arguments as the user typed them. Globs (`**`, - /// `*`, `?`) are expanded here. - pub files: &'a [String], -} - -/// Run the command. Returns an [`ExitCode`] reflecting the final -/// verdict so the caller can plumb it through to the process -/// exit. Network failures (quarantine / upload) do NOT propagate -/// as errors — they print to the report (and annotate on GitHub -/// Actions) and the run continues. The only `Err` paths are -/// argument resolution failures (e.g. missing token) and -/// unrecoverable input errors (no XML, parse failure on every -/// file). -pub async fn run( - opts: JunitProcessOptions<'_>, - output: &mut dyn Output, -) -> Result { - run_with_cap(opts, output, upload::MAX_GZIPPED_UPLOAD_BYTES).await -} - -/// Body of [`run`], parameterized on the per-upload gzip cap so tests -/// can force the split path with a small cap instead of a -/// multi-megabyte fixture. Production always passes -/// [`upload::MAX_GZIPPED_UPLOAD_BYTES`]. -#[allow(clippy::too_many_lines)] // Straight-line orchestration: parse → -// quarantine → build spans → upload → render. Splitting this into -// helpers spreads the report builder's ordering across the file -// without buying anything you can't already see by scrolling. -async fn run_with_cap( - opts: JunitProcessOptions<'_>, - output: &mut dyn Output, - upload_cap: usize, -) -> Result { - // ── Resolve required inputs up front so we fail before - // printing any of the banner — matches Python's click defaults - // surfacing as exit code 2 when a required flag is missing, - // before the command body runs. - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::ApplicationKey)?; - let repository = detector::resolve_repository(opts.repository)?; - let tests_target_branch = resolve_tests_target_branch(opts.tests_target_branch)?; - let test_exit_code = resolve_test_exit_code(opts.test_exit_code)?; - let files = expand_files(opts.files)?; - - // ── Header (printed regardless of outcome). - let mut report = String::new(); - write_header(&mut report); - - // ── Parse. A parse failure aborts before the upload step - // (Python returns ExitCode.GENERIC_ERROR with an inline error - // banner; we do the same). - let parsed = match parse_all(&files) { - Ok(p) => p, - Err(err) => { - write_early_exit( - &mut report, - &format!("Failed to parse JUnit XML: {err}"), - "Check that your test framework is generating valid JUnit XML output.", - ); - emit(output, &report)?; - return Ok(ExitCode::GenericError); - } - }; - - // Two distinct early exits, mirroring Python. When the files - // yield no suites at all the session/suite spans are absent, so - // "No spans found" is the right message. When suites parsed but - // hold zero test cases, the spans exist — the actual problem is - // a test step that never produced results. - if parsed.suite_names.is_empty() { - write_early_exit( - &mut report, - "No spans found in the JUnit files", - "Check that the JUnit XML files are not empty.", - ); - emit(output, &report)?; - return Ok(ExitCode::GenericError); - } - - if parsed.cases.is_empty() { - write_early_exit( - &mut report, - "No test cases found in the JUnit files", - "Check that your test step ran successfully before this step.", - ); - emit(output, &report)?; - return Ok(ExitCode::GenericError); - } - - // ── Quarantine check (best effort). Failures here don't - // abort — we fall back to "treat every failure as blocking". - let quarantine_result = quarantine::check_failing( - &api_url, - &token, - &repository, - &tests_target_branch, - &parsed.cases, - ) - .await; - - let (quarantine_result, quarantine_error) = match quarantine_result { - Ok(r) => (r, None::), - Err(QuarantineFailed { message }) => ( - // Conservative fallback: every failure is treated as - // blocking, none as quarantined. - blocking_fallback(&parsed.cases), - Some(message), - ), - }; - - // ── Build spans + upload (best effort). The quarantined set - // gets folded into each case span's `cicd.test.quarantined` - // attribute at build time, so we don't need to re-tag after - // the fact. - let metadata = UploadMetadata { - test_framework: opts.test_framework.map(str::to_string), - test_language: opts.test_language.map(str::to_string), - mergify_test_job_name: env::var_non_empty("MERGIFY_TEST_JOB_NAME"), - quarantined: quarantine_result - .quarantined - .iter() - .map(|c| c.name.clone()) - .collect(), - }; - let mut built = spans::build_traces(&parsed, &metadata); - - // Size each gzipped upload against MAX_GZIPPED_UPLOAD_BYTES. A - // normal report is one chunk, and byte-identical to before as - // long as it lost nothing; only an oversized payload fans out - // into several uploads. - // - // The cases already refused on name length go in as well: the - // split is what declares a session amputated, and that count has - // to cover every result missing from the upload, not only the - // ones this step drops. Counted before the two lists are merged - // below, which is the same total by construction. - let (chunks, mut oversized_cases, mut upload_error) = - match split::split_request(&built.request, upload_cap, built.oversized_case_names.len()) { - Ok(outcome) => (outcome.chunks, outcome.oversized_cases, None), - // gzip is an in-memory write and effectively never fails, - // but if it does we surface it as an upload error rather - // than dropping the run silently. - Err(e) => ( - Vec::new(), - Vec::new(), - Some(upload::UploadError { - status: None, - message: format!("failed to gzip OTLP payload: {e}"), - }), - ), - }; - - // Cases the span builder refused on name length join the ones the split - // refused on payload size: from the user's side both are "this result was - // not uploaded", and one list is what they need to act on. - // - // Moved, not cloned. Every string in here is over MAX_TEST_NAME_BYTES -- - // that is why it was refused -- so copying them would allocate another - // 65 kB apiece on the one path guaranteed to be holding the biggest names - // in the run. `built` is not read for this field again. - oversized_cases.append(&mut built.oversized_case_names); - - let client = upload::default_client(); - // Nothing reached the backend when the split produced no chunks - // (every case individually oversized) or gzip failed. Captured - // before the loop consumes `chunks`. That must not be reported as - // a successful upload. - let nothing_uploaded = chunks.is_empty(); - // Since a fanned-out upload declares its own total (see `split`), - // "4 of 5 chunks sent" is no longer a transient hiccup: it leaves - // a session a backend can see is unfinished — once it reads the - // declaration, which is MRGFY-9121 and not yet deployed. This - // process is the only place that ever knew the total, so the line - // is worth printing before that lands. - let chunks_produced = chunks.len(); - let mut chunks_accepted = 0usize; - - // Post each chunk. Stop early only on a permanent rejection (bad - // token / no ingest access) — it would fail every remaining chunk - // identically. A transient failure on one chunk doesn't abandon - // the rest: each chunk is an idempotent self-contained trace, so - // delivering as many as possible is strictly better than dropping - // the tail. The chunk is consumed so its (multi-MiB) gzipped body - // moves into the request instead of being cloned per attempt. - for chunk in chunks { - if let Some(err) = upload::upload_compressed( - &client, - api_url.as_str(), - &token, - &repository, - chunk.compressed, - ) - .await - .err() - { - let permanent = err.is_rejection(); - upload_error = Some(err); - if permanent { - break; - } - } else { - chunks_accepted += 1; - } - } - - let upload_failed = upload_error.is_some() || nothing_uploaded; - maybe_write_github_output(upload_status_label(upload_error.as_ref(), nothing_uploaded)); - - // ── Report sections — order matches Python verbatim. - write_run_id(&mut report, &built.run_id); - - let total_cases = count_test_cases(&parsed); - let nb_failures = count_failures(&parsed); - write_upload_summary( - &mut report, - files.len(), - total_cases, - nb_failures, - upload_failed, - ); - write_chunked_delivery(&mut report, chunks_produced, chunks_accepted); - - if let Some(err) = &upload_error { - write_upload_error_block(&mut report, &err.to_string(), err.is_rejection()); - if let Some(annotation) = gha_upload_annotation(err) { - report.push_str(&annotation); - report.push('\n'); - } - } - - if !oversized_cases.is_empty() { - write_oversized_cases(&mut report, &oversized_cases); - if let Some(annotation) = gha_oversized_annotation(&oversized_cases) { - report.push_str(&annotation); - report.push('\n'); - } - } - - write_quarantine_section(&mut report, &quarantine_result, quarantine_error.as_deref()); - - // ── Silent-failure detection. If the test runner exited - // non-zero but the JUnit report has no failures, the runner - // probably crashed — fail loudly so the user knows the report - // is incomplete. - if let Some(exit_code) = test_exit_code - && exit_code != 0 - && nb_failures == 0 - { - write_silent_failure(&mut report, exit_code); - emit(output, &report)?; - return Ok(ExitCode::GenericError); - } - - // ── Verdict. - let final_failure_message = - quarantine_failure_message(&quarantine_result, nb_failures, quarantine_error.is_some()); - let nb_quarantined_failures = quarantine_result.failing.len(); - write_verdict( - &mut report, - final_failure_message.as_deref(), - nb_quarantined_failures, - ); - - emit(output, &report)?; - - Ok(if final_failure_message.is_some() { - ExitCode::GenericError - } else { - ExitCode::Success - }) -} - -fn emit(output: &mut dyn Output, report: &str) -> Result<(), CliError> { - output - .emit(&(), &mut |w| w.write_all(report.as_bytes())) - .map_err(|e| CliError::Generic(format!("could not write output: {e}"))) -} - -/// Resolve `--test-exit-code` / `MERGIFY_TEST_EXIT_CODE`. Empty -/// env value is treated as "unset" so callers like the -/// `gha-mergify-ci` action can export `MERGIFY_TEST_EXIT_CODE=""` -/// to mean "no exit code available" without tripping a clap -/// parse error — the env-var resolution intentionally does not -/// go through clap's `env = ...` attribute, see the -/// `JunitProcessCliArgs::test_exit_code` field for the full -/// rationale. -fn resolve_test_exit_code(explicit: Option) -> Result, CliError> { - if explicit.is_some() { - return Ok(explicit); - } - let Some(raw) = env::var_non_empty("MERGIFY_TEST_EXIT_CODE") else { - return Ok(None); - }; - raw.parse::().map(Some).map_err(|e| { - CliError::Configuration(format!( - "MERGIFY_TEST_EXIT_CODE={raw:?} is not a valid integer: {e}", - )) - }) -} - -fn resolve_tests_target_branch(explicit: Option<&str>) -> Result { - let raw = if let Some(v) = explicit.filter(|s| !s.is_empty()) { - v.to_string() - } else { - detector::get_tests_target_branch().ok_or_else(|| { - CliError::Configuration( - "--tests-target-branch not provided and could not be detected".to_string(), - ) - })? - }; - // Mirror Python's `_process_tests_target_branch` callback that - // strips `refs/heads/` so the branch name matches what the - // quarantine API expects. - Ok(raw.strip_prefix("refs/heads/").unwrap_or(&raw).to_string()) -} - -/// Expand each `files` entry: existing literal paths take -/// precedence over glob expansion (so `report[1].xml` keeps -/// working), then `**`/`*`/`?` patterns get expanded. A glob that -/// matches nothing is a hard error — same as Python's behavior, -/// since "no test reports" almost always means a previous CI step -/// silently failed and we want the user to see it. -fn expand_files(raw: &[String]) -> Result, CliError> { - if raw.is_empty() { - return Err(CliError::Configuration( - "at least one JUnit XML file path is required".to_string(), - )); - } - // Preserve insertion order while deduplicating — `Vec` - // is small (a handful of XML reports per CI step), so a linear - // `contains` check on each insert is cheaper than a `BTreeSet` - // and keeps ordering deterministic across runs. - let mut out: Vec = Vec::new(); - for entry in raw { - let literal = Path::new(entry); - if literal.is_file() { - if !out.iter().any(|p| p == literal) { - out.push(literal.to_path_buf()); - } - continue; - } - if has_glob_magic(entry) { - let matches = glob::glob(entry).map_err(|e| { - CliError::Configuration(format!("invalid glob pattern {entry:?}: {e}")) - })?; - let mut any_match = false; - for m in matches { - let path = m.map_err(|e| { - CliError::Configuration(format!("glob walk failed for {entry:?}: {e}")) - })?; - if !path.is_file() { - continue; - } - if !out.iter().any(|p| p == &path) { - out.push(path); - } - any_match = true; - } - if !any_match { - return Err(CliError::Configuration(format!( - "Pattern '{entry}' did not match any file.\n\n\ - This usually indicates that a previous CI step failed to generate the test results.\n\ - Please check if your test execution step completed successfully and produced the expected output files." - ))); - } - continue; - } - if literal.is_dir() { - return Err(CliError::Configuration(format!( - "'{entry}' is a directory, not a JUnit XML file.\n\n\ - Pass a file path or a quoted glob pattern (e.g. 'reports/**/*.xml') instead." - ))); - } - return Err(CliError::Configuration(format!( - "JUnit XML file '{entry}' does not exist.\n\n\ - This usually indicates that a previous CI step failed to generate the test results.\n\ - Please check if your test execution step completed successfully and produced the expected output file." - ))); - } - Ok(out) -} - -fn has_glob_magic(s: &str) -> bool { - s.contains(['*', '?', '[']) -} - -fn parse_all(files: &[PathBuf]) -> Result { - // Concatenate the cases from every file. The OTLP layer - // doesn't care which file a case came from — JUnit suites - // already group them, and that grouping is what becomes a - // suite span downstream. `suite_names` from each file is - // appended in order; the OTLP span builder dedupes via its - // `group_by_suite` linear scan. - let mut all_cases = Vec::new(); - let mut all_suite_names = Vec::new(); - for path in files { - let bytes = std::fs::read(path).map_err(|e| junit::InvalidJunitXml { - details: format!("cannot read {}: {e}", path.display()), - })?; - let parsed = junit::parse(&bytes)?; - all_suite_names.extend(parsed.suite_names); - all_cases.extend(parsed.cases); - } - Ok(ParseResult { - suite_names: all_suite_names, - cases: all_cases, - }) -} - -fn count_test_cases(parsed: &ParseResult) -> usize { - parsed.cases.len() -} - -fn count_failures(parsed: &ParseResult) -> usize { - parsed - .cases - .iter() - .filter(|c| c.status.is_failure()) - .count() -} - -fn blocking_fallback(cases: &[TestCase]) -> QuarantineResult { - let failing: Vec = cases - .iter() - .filter(|c| c.status.is_failure()) - .cloned() - .collect(); - QuarantineResult { - non_quarantined: failing.clone(), - failing, - quarantined: Vec::new(), - } -} - -fn quarantine_failure_message( - result: &QuarantineResult, - nb_failures: usize, - quarantine_errored: bool, -) -> Option { - if quarantine_errored { - return Some(format!( - "Treating {nb_failures}/{nb_failures} failures as blocking" - )); - } - if !result.non_quarantined.is_empty() { - let count = result.non_quarantined.len(); - let total = result.failing.len(); - let quarantined = total - count; - return Some(format!("{quarantined}/{total} failures quarantined")); - } - None -} - -fn write_header(out: &mut String) { - out.push_str(SEPARATOR); - out.push('\n'); - out.push_str(" 🚀 CI Insights\n"); - out.push('\n'); - out.push_str(concat!( - " Uploads JUnit test results to Mergify CI Insights and evaluates\n", - " quarantine status for failing tests. This step determines the\n", - " final CI status — quarantined failures are ignored.\n", - " Learn more: https://docs.mergify.com/ci-insights/quarantine\n", - )); - out.push_str(SEPARATOR); - out.push('\n'); -} - -fn write_run_id(out: &mut String, run_id: &str) { - out.push('\n'); - out.push_str(&format!(" Run ID: {run_id}\n")); -} - -fn write_upload_summary( - out: &mut String, - reports: usize, - tests: usize, - failures: usize, - upload_failed: bool, -) { - let reports_label = format!( - "{reports} {kind}", - kind = if reports == 1 { "report" } else { "reports" } - ); - let failures_label = format!( - "{failures} {kind}", - kind = if failures == 1 { "failure" } else { "failures" } - ); - if upload_failed { - out.push_str(&format!(" ☁️ {reports_label} not uploaded\n")); - } else { - out.push_str(&format!(" ☁️ {reports_label} uploaded\n")); - } - out.push_str(&format!(" 🧪 {tests} tests ({failures_label})\n")); -} - -/// Report how a fanned-out session was delivered. "Chunk" is the -/// wire's own word for one upload of a split session -/// (`mergify.test.session.chunk.count`); using it here too means an -/// operator reading this line and someone grepping the backend look -/// for the same term. -/// -/// Silent for the ordinary single-upload run, which must keep reading -/// exactly as it does today. Counts only, never a line per chunk: a -/// report large enough to split can produce arbitrarily many, and no -/// individual one is worth a line in a CI log. -/// -/// The incompleteness wording is claimed only where it is true — some -/// chunks landed and the rest did not, so what ingest holds is a -/// session that will never reach its declared total (readable as such -/// once MRGFY-9121 ships). When nothing landed there is no such -/// session, only a failed upload, which -/// [`write_upload_error_block`] already explains. -fn write_chunked_delivery(out: &mut String, produced: usize, accepted: usize) { - if produced <= 1 { - return; - } - if accepted == produced { - out.push_str(&format!(" ☁️ sent in {produced} chunks\n")); - } else if accepted == 0 { - out.push_str(&format!(" ☁️ none of the {produced} chunks sent\n")); - } else { - out.push_str(&format!( - " ☁️ only {accepted} of {produced} chunks sent — this test session is incomplete\n" - )); - } -} - -/// `$GITHUB_OUTPUT` key reporting the upload outcome: -/// `success`, `rejected` (4xx except 408/429 — permanent, e.g. -/// bad token), or `failed` (transient: 5xx, 408, 429, network, or -/// nothing reached ingest because every case was individually -/// oversized). Lets workflows detect dead ingest programmatically -/// without parsing the human report (issue #1571). -const UPLOAD_STATUS_OUTPUT_NAME: &str = "test_results_upload"; - -fn upload_status_label( - error: Option<&upload::UploadError>, - nothing_uploaded: bool, -) -> &'static str { - match error { - Some(e) if e.is_rejection() => "rejected", - Some(_) => "failed", - // No HTTP error, but the split produced no chunks (every case - // individually oversized) or gzip failed: nothing reached the - // backend, so it is not a success. - None if nothing_uploaded => "failed", - None => "success", - } -} - -/// Append `test_results_upload=` to `$GITHUB_OUTPUT` when -/// the env var is set (GitHub Actions). Best effort — reporting -/// plumbing must never break the run, so an unwritable file warns -/// on stderr instead of erroring. -fn maybe_write_github_output(status: &str) { - use std::io::Write as _; - let Some(path) = env::var_non_empty("GITHUB_OUTPUT") else { - return; - }; - let result = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .and_then(|mut f| writeln!(f, "{UPLOAD_STATUS_OUTPUT_NAME}={status}")); - if let Err(e) = result { - eprintln!( - "warning: could not write {UPLOAD_STATUS_OUTPUT_NAME} to GITHUB_OUTPUT ({path}): {e}", - ); - } -} - -/// GitHub Actions workflow-command annotation for an upload -/// failure, or `None` outside GitHub Actions. The runner parses -/// the `::error::`/`::warning::` line out of stdout and surfaces -/// it in the run summary and checks UI without affecting the step -/// outcome — upload trouble must stay visible but never break -/// customer CI (issue #1571). Rejections (4xx except 408/429, see -/// [`upload::UploadError::is_rejection`]) annotate as errors since -/// they're permanent misconfiguration; transient failures (5xx, -/// 408, 429, network) as warnings. -fn gha_upload_annotation(error: &upload::UploadError) -> Option { - if env::var("GITHUB_ACTIONS").as_deref() != Some("true") { - return None; - } - Some(if error.is_rejection() { - let status = error.status.expect("a rejection always has an HTTP status"); - format!( - "::error title=Mergify Test Insights::Test results upload rejected (HTTP {status}). \ - No test data reached Test Insights — check that your token has CI Insights access \ - to this repository." - ) - } else { - format!( - "::warning title=Mergify Test Insights::Failed to upload test results to Mergify \ - Test Insights ({err}). Test data for this run was not recorded.", - err = gha_escape_data(&error.to_string()), - ) - }) -} - -/// GitHub Actions `::warning::` annotation for test cases dropped -/// because they were individually too large to upload, or `None` -/// outside GitHub Actions. Makes the data loss visible in the run -/// summary / checks UI — otherwise it only appears in the human -/// report prose. Never an error: the CI outcome is unaffected. -fn gha_oversized_annotation(names: &[String]) -> Option { - if env::var("GITHUB_ACTIONS").as_deref() != Some("true") { - return None; - } - Some(format!( - "::warning title=Mergify Test Insights::{n} test result(s) were too large to upload \ - and were skipped: {names}. The rest of the run was uploaded.", - n = names.len(), - names = gha_escape_data( - &names - .iter() - .map(|n| display_name(n)) - .collect::>() - .join(", "), - ), - )) -} - -/// Escape the data section of a GHA workflow command. A multi-line -/// value (e.g. an HTTP response body inside the error message) -/// would otherwise terminate the command at the first newline. -fn gha_escape_data(s: &str) -> String { - s.replace('%', "%25") - .replace('\r', "%0D") - .replace('\n', "%0A") -} - -fn write_upload_error_block(out: &mut String, error: &str, rejected: bool) { - out.push_str("\n ⚠️ Failed to upload test results\n"); - out.push_str(" Mergify CI Insights won't process these test results.\n"); - out.push_str(" Quarantine status and CI outcome are unaffected.\n"); - if rejected { - out.push_str(" The API rejected the upload — check that your token has\n"); - out.push_str(" CI Insights access to this repository.\n"); - } - out.push('\n'); - out.push_str(" ┌ Details\n"); - for line in error.lines() { - out.push_str(&format!(" │ {line}\n")); - } - out.push_str(" └─\n"); -} - -/// Report test cases dropped from the upload because a single case -/// gzips past [`upload::MAX_GZIPPED_UPLOAD_BYTES`] on its own — there -/// is no upload it can fit into. The CI verdict is unaffected (it's -/// computed from the parsed cases, not the upload), so this is a -/// best-effort data-loss notice, not a failure. -/// How much of a skipped test's name the report prints. A name can be the -/// reason it was skipped, so printing it whole would bury the report under -/// the same 65 kB that caused the problem. -const SKIPPED_NAME_DISPLAY_BYTES: usize = 120; - -/// `name` cut to [`SKIPPED_NAME_DISPLAY_BYTES`] on a character boundary, -/// marked when it was cut so nobody copies the prefix as the real name. -fn display_name(name: &str) -> String { - if name.len() <= SKIPPED_NAME_DISPLAY_BYTES { - return name.to_string(); - } - let mut end = SKIPPED_NAME_DISPLAY_BYTES; - while end > 0 && !name.is_char_boundary(end) { - end -= 1; - } - format!("{}… ({} bytes total)", &name[..end], name.len()) -} - -fn write_oversized_cases(out: &mut String, names: &[String]) { - out.push_str("\n ⚠️ Some test results were skipped\n"); - out.push_str(" A test whose name or output is too large to upload is skipped; the rest\n"); - out.push_str(" of the run was uploaded. Rename the test to get its history back.\n"); - out.push_str(" Quarantine status and CI outcome are unaffected.\n"); - out.push('\n'); - out.push_str(" ┌ Skipped\n"); - for name in names { - out.push_str(&format!(" │ {}\n", display_name(name))); - } - out.push_str(" └─\n"); -} - -fn write_quarantine_section(out: &mut String, result: &QuarantineResult, error: Option<&str>) { - // Skip the whole section when there's nothing to say — Python - // does the same `if not result.failing_spans and error is None` - // early return. - if result.failing.is_empty() && error.is_none() { - return; - } - out.push('\n'); - out.push_str(SEPARATOR_LIGHT); - out.push('\n'); - out.push('\n'); - out.push_str("🛡️ Quarantine\n"); - - if let Some(err) = error { - out.push('\n'); - out.push_str(" ⚠️ Failed to check quarantine status\n"); - out.push_str(" Contact Mergify support if this error persists.\n"); - out.push('\n'); - out.push_str(" ┌ Details\n"); - for line in err.lines() { - out.push_str(&format!(" │ {line}\n")); - } - out.push_str(" └─\n"); - } - - if !result.quarantined.is_empty() { - out.push_str(&format!( - "\n 🔒 Quarantined ({n}):\n", - n = result.quarantined.len() - )); - for case in &result.quarantined { - out.push_str(&format!(" · {name}\n", name = case.name)); - } - } - - if !result.non_quarantined.is_empty() { - let label = if error.is_some() { - "Could not verify quarantine status" - } else { - "Unquarantined" - }; - out.push_str(&format!( - "\n ❌ {label} ({n}):\n", - n = result.non_quarantined.len() - )); - for case in &result.non_quarantined { - write_failure_block(out, case); - } - } -} - -fn write_failure_block(out: &mut String, case: &TestCase) { - out.push_str(&format!("\n ┌ {name}\n", name = case.name)); - let f = &case.failure; - if f.kind.is_none() && f.message.is_none() && f.stacktrace.is_none() { - out.push_str(" │\n"); - out.push_str(" │ (no error details in JUnit report)\n"); - out.push_str(" └─\n"); - return; - } - if f.kind.is_some() || f.message.is_some() { - let parts: Vec<&str> = [f.kind.as_deref(), f.message.as_deref()] - .into_iter() - .flatten() - .collect(); - out.push_str(" │\n"); - out.push_str(&format!(" │ {joined}\n", joined = parts.join(": "))); - } - if let Some(stack) = &f.stacktrace { - out.push_str(" │\n"); - for line in stack.lines() { - out.push_str(&format!(" │ {line}\n")); - } - } - out.push_str(" └─\n"); -} - -fn write_silent_failure(out: &mut String, test_exit_code: i32) { - out.push('\n'); - out.push_str(SEPARATOR_LIGHT); - out.push('\n'); - out.push('\n'); - out.push_str(&format!( - " ⚠️ Test runner exited with an error (exit code: {test_exit_code})\n" - )); - out.push_str(" but no test failures appear in the JUnit report.\n"); - out.push_str(" The report may be incomplete — check your test runner logs.\n"); - out.push('\n'); - out.push_str(SEPARATOR); - out.push('\n'); - out.push_str("❌ FAIL — test runner exited with an error but no failures were reported\n"); - out.push_str(&format!( - " Exit code: {code}\n", - code = ExitCode::GenericError.as_u8() - )); - out.push_str(SEPARATOR); - out.push('\n'); -} - -fn write_verdict(out: &mut String, failure_message: Option<&str>, nb_quarantined_failures: usize) { - out.push('\n'); - out.push_str(SEPARATOR); - out.push('\n'); - if let Some(msg) = failure_message { - out.push_str(&format!("❌ FAIL — {msg}\n")); - out.push_str(" Exit code: 1\n"); - } else if nb_quarantined_failures == 0 { - out.push_str("✅ OK — all tests passed, no quarantine needed\n"); - out.push_str(" Exit code: 0\n"); - } else { - let n = nb_quarantined_failures; - out.push_str(&format!( - "✅ OK — {n}/{n} failures quarantined, CI status unaffected\n", - )); - out.push_str(" Exit code: 0\n"); - } - out.push_str(SEPARATOR); - out.push('\n'); -} - -fn write_early_exit(out: &mut String, message: &str, hint: &str) { - out.push_str(&format!("❌ FAIL — {message}\n")); - out.push_str(&format!(" {hint}\n")); - out.push_str(" Exit code: 1\n"); - out.push_str(SEPARATOR); - out.push('\n'); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::junit_process::junit::{Failure, TestStatus}; - use std::time::Duration; - - fn case(name: &str, status: TestStatus) -> TestCase { - TestCase { - name: name.to_string(), - suite_name: "s".to_string(), - duration: Some(Duration::from_secs(0)), - file: None, - line: None, - status, - failure: Failure::default(), - } - } - - #[test] - fn resolve_test_exit_code_returns_explicit_value_when_provided() { - // Explicit `--test-exit-code 0` must win over whatever the - // env var says — including the empty-string sentinel the - // `gha-mergify-ci` action uses when no runner exit code is - // available. Pin so a future refactor can't accidentally - // invert the precedence. - let got = env::testing::with_var("MERGIFY_TEST_EXIT_CODE", Some("42"), || { - resolve_test_exit_code(Some(0)).unwrap() - }); - assert_eq!(got, Some(0)); - } - - #[test] - fn resolve_test_exit_code_treats_empty_env_var_as_unset() { - // Regression for the downstream `gha-mergify-ci` break - // (monorepo#33423, second symptom): the action exports - // `MERGIFY_TEST_EXIT_CODE=""` when the previous step - // didn't produce a runner exit code. Previously the clap - // `env = "MERGIFY_TEST_EXIT_CODE"` attribute on - // `--test-exit-code` tried to parse the empty value as - // `i32` and aborted parsing with `cannot parse integer - // from empty string`, before this function ever ran. The - // fix drops the clap `env` hook and routes the env var - // through here — empty must collapse to `None`, the - // same shape no env var would produce. - let got = env::testing::with_var("MERGIFY_TEST_EXIT_CODE", Some(""), || { - resolve_test_exit_code(None).unwrap() - }); - assert_eq!(got, None); - } - - #[test] - fn resolve_test_exit_code_parses_non_empty_env_var() { - let got = env::testing::with_var("MERGIFY_TEST_EXIT_CODE", Some("7"), || { - resolve_test_exit_code(None).unwrap() - }); - assert_eq!(got, Some(7)); - } - - #[test] - fn resolve_test_exit_code_errors_when_env_var_is_non_empty_garbage() { - // A non-empty env var that isn't a valid integer is a - // real misconfiguration, not a "no value" sentinel — - // error loudly with the offending value in the message so - // the user can spot the typo without having to dig. - let err = env::testing::with_var("MERGIFY_TEST_EXIT_CODE", Some("not-an-int"), || { - resolve_test_exit_code(None).unwrap_err() - }); - let msg = err.to_string(); - assert!(msg.contains("MERGIFY_TEST_EXIT_CODE="), "got: {msg}"); - assert!(msg.contains("not-an-int"), "got: {msg}"); - } - - #[test] - fn quarantine_failure_message_signals_blocking_when_check_errored() { - let result = QuarantineResult { - failing: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], - non_quarantined: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], - quarantined: vec![], - }; - let msg = quarantine_failure_message(&result, 2, true); - // Pythonic phrasing: "Treating X/X failures as blocking". - assert_eq!(msg.as_deref(), Some("Treating 2/2 failures as blocking")); - } - - #[test] - fn quarantine_failure_message_says_quarantined_when_some_pass() { - let result = QuarantineResult { - failing: vec![case("a", TestStatus::Failed), case("b", TestStatus::Failed)], - quarantined: vec![case("a", TestStatus::Failed)], - non_quarantined: vec![case("b", TestStatus::Failed)], - }; - // 1/2 still blocking → message says "1/2 quarantined". - let msg = quarantine_failure_message(&result, 2, false); - assert_eq!(msg.as_deref(), Some("1/2 failures quarantined")); - } - - #[test] - fn quarantine_failure_message_none_when_all_quarantined() { - let result = QuarantineResult { - failing: vec![case("a", TestStatus::Failed)], - quarantined: vec![case("a", TestStatus::Failed)], - non_quarantined: vec![], - }; - // Every failure quarantined → no failure message. - assert_eq!(quarantine_failure_message(&result, 1, false), None); - } - - #[test] - fn expand_files_rejects_unknown_path() { - let err = expand_files(&["does/not/exist.xml".to_string()]).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("does/not/exist.xml") && msg.contains("does not exist"), - "got: {msg}" - ); - } - - #[test] - fn expand_files_rejects_directory() { - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("sub"); - std::fs::create_dir(&dir).unwrap(); - let err = expand_files(&[dir.to_string_lossy().to_string()]).unwrap_err(); - assert!(err.to_string().contains("directory"), "got: {err}"); - } - - #[test] - fn expand_files_dedupes_repeated_literal_paths() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("a.xml"); - std::fs::write(&path, b"x").unwrap(); - let raw = path.to_string_lossy().to_string(); - let out = expand_files(&[raw.clone(), raw]).unwrap(); - assert_eq!(out.len(), 1); - } - - #[test] - fn expand_files_rejects_pattern_with_no_matches() { - let tmp = tempfile::tempdir().unwrap(); - // tempdir is empty — a wildcard for *.xml here matches nothing. - let pattern = tmp.path().join("*.xml").to_string_lossy().to_string(); - let err = expand_files(&[pattern]).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("did not match any file"), "got: {msg}"); - } - - #[test] - fn write_verdict_renders_blocking_failure() { - let mut s = String::new(); - write_verdict(&mut s, Some("1/2 failures quarantined"), 0); - // The "Exit code: 1" line is what users grep for in CI logs. - assert!(s.contains("❌ FAIL — 1/2 failures quarantined"), "{s}"); - assert!(s.contains("Exit code: 1"), "{s}"); - } - - #[test] - fn write_verdict_renders_all_pass() { - let mut s = String::new(); - write_verdict(&mut s, None, 0); - assert!(s.contains("✅ OK — all tests passed"), "{s}"); - assert!(s.contains("Exit code: 0"), "{s}"); - } - - #[test] - fn write_verdict_renders_all_quarantined_pass() { - let mut s = String::new(); - write_verdict(&mut s, None, 3); - // "3/3 failures quarantined" — the second arm of the verdict. - assert!(s.contains("3/3 failures quarantined"), "{s}"); - assert!(s.contains("Exit code: 0"), "{s}"); - } - - #[test] - fn write_failure_block_handles_missing_details() { - let mut s = String::new(); - write_failure_block(&mut s, &case("orphan", TestStatus::Failed)); - assert!(s.contains("(no error details in JUnit report)"), "{s}"); - } - - #[test] - fn write_failure_block_joins_kind_and_message() { - let mut s = String::new(); - let mut c = case("t", TestStatus::Failed); - c.failure.kind = Some("AssertionError".to_string()); - c.failure.message = Some("assert 1 == 0".to_string()); - c.failure.stacktrace = Some("line1\nline2".to_string()); - write_failure_block(&mut s, &c); - // Kind and message joined with ": ", stacktrace lines each - // prefixed with the box-drawing column. - assert!(s.contains("AssertionError: assert 1 == 0"), "{s}"); - assert!(s.contains("│ line1"), "{s}"); - assert!(s.contains("│ line2"), "{s}"); - } - - #[test] - fn write_oversized_cases_lists_skipped_tests() { - let mut s = String::new(); - write_oversized_cases(&mut s, &["a.big".to_string(), "b.huge".to_string()]); - assert!(s.contains("too large to upload"), "{s}"); - assert!(s.contains("a.big"), "{s}"); - assert!(s.contains("b.huge"), "{s}"); - // The verdict must stay unaffected — this is a data-loss - // notice, not a failure. - assert!(s.contains("CI outcome are unaffected"), "{s}"); - } - - #[test] - fn upload_status_label_maps_every_outcome() { - let err = |status: Option| upload::UploadError { - status, - message: String::new(), - }; - // Clean success only when something uploaded and no error. - assert_eq!(upload_status_label(None, false), "success"); - // Nothing reached ingest (every case oversized / gzip failed) - // must not read as success — issue #1571 keys on this. - assert_eq!(upload_status_label(None, true), "failed"); - // A permanent rejection stays "rejected"; anything else that - // errored is "failed", regardless of the nothing_uploaded flag. - assert_eq!( - upload_status_label(Some(&err(Some(403))), false), - "rejected" - ); - assert_eq!(upload_status_label(Some(&err(Some(503))), false), "failed"); - assert_eq!(upload_status_label(Some(&err(None)), false), "failed"); - } - - #[test] - fn gha_oversized_annotation_lists_names_only_on_gha() { - // Outside GitHub Actions: no annotation. - let none = env::testing::with_var("GITHUB_ACTIONS", None::<&str>, || { - gha_oversized_annotation(&["a.big".to_string()]) - }); - assert!(none.is_none()); - // On GitHub Actions: a warning naming the dropped tests, never - // an error (CI outcome is unaffected). - let ann = env::testing::with_var("GITHUB_ACTIONS", Some("true"), || { - gha_oversized_annotation(&["a.big".to_string(), "b.huge".to_string()]) - }) - .unwrap(); - assert!(ann.starts_with("::warning"), "{ann}"); - assert!(ann.contains("a.big, b.huge"), "{ann}"); - assert!(ann.contains("too large to upload"), "{ann}"); - } - - /// A name can itself be the reason a case was skipped, so neither the - /// report nor the annotation may print it whole -- 65 kB of generated - /// title would bury the very message telling you which test to rename. - #[test] - fn a_skipped_name_is_truncated_for_display() { - let long = "z".repeat(super::SKIPPED_NAME_DISPLAY_BYTES + 500); - - let shown = display_name(&long); - assert!(shown.len() < long.len(), "{shown}"); - // Marked as cut, and carrying the real size, so nobody copies the - // prefix believing it is the test's name. - assert!(shown.contains('…'), "{shown}"); - assert!( - shown.contains(&format!("{} bytes total", long.len())), - "{shown}" - ); - - let mut report = String::new(); - write_oversized_cases(&mut report, std::slice::from_ref(&long)); - assert!(!report.contains(&long), "the report printed the whole name"); - - let ann = env::testing::with_var("GITHUB_ACTIONS", Some("true"), || { - gha_oversized_annotation(std::slice::from_ref(&long)) - }) - .unwrap(); - assert!( - !ann.contains(&long), - "the annotation printed the whole name" - ); - } - - /// A short name is printed exactly, with no truncation marker. - #[test] - fn a_short_name_is_printed_verbatim() { - assert_eq!(display_name("tests.test_a"), "tests.test_a"); - } - - // ── End-to-end orchestrator tests. Drive the full `run()` - // entry point with on-disk fixtures and a wiremock-backed API - // so the stdout banner + exit code are pinned for each verdict - // branch (all-pass, mixed failures, empty input). Cheap to run - // — wiremock starts a server in-process and the fixtures fit - // in a string literal each — but they're the only thing that - // catches a regression in the orchestrator's wiring (e.g. the - // wrong verdict picked, the early-exit branch mis-routed, the - // banner text drifting). - mod orchestrator { - use super::*; - use mergify_core::{OutputMode, StdioOutput}; - use std::sync::{Arc, Mutex}; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - type SharedBytes = Arc>>; - struct SharedWriter(SharedBytes); - impl std::io::Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - struct Captured { - output: StdioOutput, - stdout: SharedBytes, - } - - fn captured() -> Captured { - let stdout: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let stderr: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let output = StdioOutput::with_sinks( - OutputMode::Human, - SharedWriter(Arc::clone(&stdout)), - SharedWriter(stderr), - ); - Captured { output, stdout } - } - - fn write_xml(dir: &tempfile::TempDir, name: &str, xml: &str) -> String { - let p = dir.path().join(name); - std::fs::write(&p, xml).unwrap(); - p.to_string_lossy().into_owned() - } - - // Mount permissive quarantine + upload mocks. Tests assert - // on stdout/exit-code rather than wire-level mock counts — - // the wire shape is already covered by `quarantine.rs` and - // `upload.rs` unit tests. - async fn mount_mocks(server: &MockServer) { - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "quarantined_tests": [], - }))) - .mount(server) - .await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .respond_with(ResponseTemplate::new(200)) - .mount(server) - .await; - } - - #[tokio::test] - async fn all_pass_exits_zero_with_ok_banner() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml( - &tmp, - "report.xml", - r#" - - - - - -"#, - ); - - let api_url = server.uri(); - let mut cap = captured(); - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(code, ExitCode::Success); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!(stdout.contains("✅ OK — all tests passed"), "{stdout}"); - assert!(stdout.contains("Exit code: 0"), "{stdout}"); - } - - // A large report must fan out into several gzipped uploads, - // each under the cap, without changing the verdict. Driven - // through `run_with_cap` with a tiny cap and asserted at the - // wire with a per-request count. - #[tokio::test] - async fn large_report_fans_out_into_several_uploads() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - - // 30 failing cases, each carrying ~2 KiB of incompressible - // text in its body — high-entropy so gzip can't - // collapse it, which is what forces the split on the small - // cap. Failures (not passing cases) so the verdict is a - // deterministic GenericError we can assert on. - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(30, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - let cap_bytes = 4 * 1024; - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, cap_bytes) - .await - .unwrap() - }) - .await; - - // Verdict is unchanged by splitting: 30 unquarantined - // failures still fail the run. - assert_eq!(code, ExitCode::GenericError); - - // More than one POST reached the traces endpoint, and each - // body is a gzipped payload under the cap. - let uploads: Vec<_> = server - .received_requests() - .await - .unwrap() - .into_iter() - .filter(|r| r.url.path() == "/v1/repos/owner/repo/ci/traces") - .collect(); - assert!( - uploads.len() > 1, - "expected a fan-out, got {} upload(s)", - uploads.len() - ); - for req in &uploads { - assert!( - req.body.len() <= cap_bytes, - "an upload body ({} bytes) exceeded the cap", - req.body.len() - ); - } - } - - /// The `(index, count)` completeness markers carried by every - /// traces upload the server received, in arrival order, each - /// read independently — a chunk carrying one without the - /// other is a half-marked state a reader would misinterpret - /// as a declared total, and collapsing the pair would hide it. - /// Decodes the real wire body (gzip → protobuf), so this - /// asserts what the backend will read, not what the splitter - /// intended. - async fn uploaded_chunk_markers(server: &MockServer) -> Vec<(Option, Option)> { - use crate::junit_process::split::{CHUNK_COUNT_ATTRIBUTE, CHUNK_INDEX_ATTRIBUTE}; - use flate2::read::GzDecoder; - use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; - use opentelemetry_proto::tonic::common::v1::any_value::Value; - use prost::Message as _; - use std::io::Read as _; - - let mut markers = Vec::new(); - for req in server.received_requests().await.unwrap() { - if req.url.path() != "/v1/repos/owner/repo/ci/traces" { - continue; - } - let mut unzipped = Vec::new(); - GzDecoder::new(req.body.as_slice()) - .read_to_end(&mut unzipped) - .expect("upload body decompresses"); - let decoded = - ExportTraceServiceRequest::decode(unzipped.as_slice()).expect("body decodes"); - let attributes = &decoded.resource_spans[0] - .resource - .as_ref() - .expect("uploads carry a resource") - .attributes; - let read = |key: &str| { - attributes.iter().find(|kv| kv.key == key).and_then(|kv| { - match kv.value.as_ref()?.value.as_ref()? { - Value::IntValue(v) => Some(*v), - _ => None, - } - }) - }; - markers.push((read(CHUNK_INDEX_ATTRIBUTE), read(CHUNK_COUNT_ATTRIBUTE))); - } - markers - } - - /// The set of resource attribute keys on every traces upload - /// the server received, decoded from the real wire body. - async fn uploaded_resource_keys(server: &MockServer) -> Vec> { - use flate2::read::GzDecoder; - use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; - use prost::Message as _; - use std::io::Read as _; - - let mut per_upload = Vec::new(); - for req in server.received_requests().await.unwrap() { - if req.url.path() != "/v1/repos/owner/repo/ci/traces" { - continue; - } - let mut unzipped = Vec::new(); - GzDecoder::new(req.body.as_slice()) - .read_to_end(&mut unzipped) - .expect("upload body decompresses"); - let decoded = - ExportTraceServiceRequest::decode(unzipped.as_slice()).expect("body decodes"); - per_upload.push( - decoded.resource_spans[0] - .resource - .as_ref() - .expect("uploads carry a resource") - .attributes - .iter() - .map(|kv| kv.key.clone()) - .collect(), - ); - } - per_upload - } - - // Every upload of a fanned-out session must tell the backend - // that it is one piece of `n`, so a consumer answering "did - // this session report a failing test?" can tell a - // half-delivered session from a finished one instead of - // answering from the first piece to land. - #[tokio::test] - async fn fanned_out_uploads_declare_their_position_in_the_session() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(30, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - // A populated CI environment, so `build_resource` emits - // the routing and attribution attributes this test then - // checks survive stamping. With the environment scrubbed - // it would only ever emit `test.run.id`, and the check - // would pass on an empty resource. - env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ( - "GITHUB_SHA", - Some("cafe1234cafe1234cafe1234cafe1234cafe1234"), - ), - ("GITHUB_RUN_ID", Some("42")), - ], - async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }, - ) - .await; - - let markers = uploaded_chunk_markers(&server).await; - let total = i64::try_from(markers.len()).unwrap(); - assert!(total > 1, "expected a fan-out, got {total} upload(s)"); - let expected: Vec<(Option, Option)> = (1..=total) - .map(|index| (Some(index), Some(total))) - .collect(); - assert_eq!( - markers, expected, - "the delivered sequence must be 1..=n, each declaring n" - ); - - // This run lost nothing, so no upload declares a loss. - // Absence is the convention; a `0` stamped here instead - // would ride on the most ordinary fan-out there is. - assert!( - uploaded_dropped_counts(&server) - .await - .iter() - .all(Option::is_none), - "an upload declared a loss that never happened" - ); - - // Stamping extends the resource; it must not replace it. - // The markers are the only thing this code adds, so every - // routing and attribution attribute `build_resource` put - // there has to still be on every chunk — a resource - // rebuilt from scratch would lose them silently, and only - // on the split path. - for keys in uploaded_resource_keys(&server).await { - for required in [ - "test.run.id", - "vcs.repository.name", - "vcs.ref.head.revision", - "cicd.pipeline.run.id", - ] { - assert!( - keys.contains(&required.to_string()), - "a fanned-out chunk lost `{required}`; it carries {keys:?}" - ); - } - } - } - - // A fan-out where the backend takes the first chunk and - // refuses the rest leaves a session it can see is incomplete. - // Nothing else in the report says so, and this process is the - // only place that ever knew the total. - #[tokio::test] - async fn partial_fan_out_reports_the_session_as_incomplete() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "quarantined_tests": [], - }))) - .mount(&server) - .await; - // First chunk accepted, every later one transiently - // refused — a 503 keeps the loop going, so the run really - // does attempt them all and end up short. - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .respond_with(ResponseTemplate::new(200)) - .up_to_n_times(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .respond_with(ResponseTemplate::new(503).set_body_string("try later")) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(30, 2048)); - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }) - .await; - - let produced = traces_requests(&server).await; - assert!(produced > 1, "expected a fan-out, got {produced} upload(s)"); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!( - stdout.contains(&format!( - "only 1 of {produced} chunks sent — this test session is incomplete" - )), - "{stdout}" - ); - } - - // A fan-out whose very first chunk is permanently rejected - // never declares anything: the loop breaks, nothing reached - // ingest, and there is no session for the backend to hold. So - // the counts are stated without claiming an incomplete - // session — that claim belongs to a delivery that partly - // landed, and asserting it here would be false. - #[tokio::test] - async fn a_fan_out_rejected_outright_claims_no_incomplete_session() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 403).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(30, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }) - .await; - - // A permanent rejection stops the loop after one attempt, - // so the request count is 1 while the split produced many. - assert_eq!(traces_requests(&server).await, 1); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!(stdout.contains("none of the"), "{stdout}"); - assert!(stdout.contains("chunks sent"), "{stdout}"); - assert!( - !stdout.contains("this test session is incomplete"), - "nothing reached ingest, so no session is incomplete: {stdout}" - ); - } - - // The same fan-out, fully delivered: the counts are stated - // without the incompleteness wording. - #[tokio::test] - async fn fully_delivered_fan_out_reports_its_part_count() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(30, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }) - .await; - - let produced = traces_requests(&server).await; - assert!(produced > 1, "expected a fan-out, got {produced} upload(s)"); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!( - stdout.contains(&format!("sent in {produced} chunks")), - "{stdout}" - ); - assert!(!stdout.contains("incomplete"), "{stdout}"); - } - - // The ordinary run must keep reading exactly as it does - // today: no chunking language at all. - #[tokio::test] - async fn single_upload_reports_no_chunk_count() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", ONE_PASSING_TEST_XML); - - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(traces_requests(&server).await, 1); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!(!stdout.contains("chunks"), "{stdout}"); - } - - // The compatibility hinge, asserted at the wire: a report - // that fits in one upload sends neither attribute. Each is - // read separately — half a marker is worse than none, since a - // lone count reads as a declared total. - async fn uploaded_dropped_counts(server: &MockServer) -> Vec> { - use crate::junit_process::split::DROPPED_CASES_ATTRIBUTE; - use flate2::read::GzDecoder; - use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; - use opentelemetry_proto::tonic::common::v1::any_value::Value; - use prost::Message as _; - use std::io::Read as _; - - let mut counts = Vec::new(); - for req in server.received_requests().await.unwrap() { - if req.url.path() != "/v1/repos/owner/repo/ci/traces" { - continue; - } - let mut unzipped = Vec::new(); - GzDecoder::new(req.body.as_slice()) - .read_to_end(&mut unzipped) - .expect("upload body decompresses"); - let decoded = - ExportTraceServiceRequest::decode(unzipped.as_slice()).expect("body decodes"); - counts.push( - decoded.resource_spans[0] - .resource - .as_ref() - .expect("uploads carry a resource") - .attributes - .iter() - .find(|kv| kv.key == DROPPED_CASES_ATTRIBUTE) - .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? { - Value::IntValue(v) => Some(*v), - _ => None, - }), - ); - } - counts - } - - // A run that fits in one upload and lost a case to the name - // limit: complete as far as the sequence goes, and still - // missing a result. The count has to reach the wire, or the - // session reads as whole. - #[tokio::test] - async fn a_single_upload_missing_a_case_declares_the_loss() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - // Two refused names, not one: this is the only test - // covering the whole name-limit → wire path, so it has to - // pin that the count says *how many* results are missing - // and not merely that some are. - let too_long = "x".repeat(spans::MAX_TEST_NAME_BYTES + 1); - let also_too_long = "y".repeat(spans::MAX_TEST_NAME_BYTES + 1); - let file = write_xml( - &tmp, - "report.xml", - &format!( - r#" - - - - - - -"# - ), - ); - - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(uploaded_dropped_counts(&server).await, vec![Some(2)]); - // One upload: no sequence to declare. - assert_eq!(uploaded_chunk_markers(&server).await, vec![(None, None)]); - } - - #[tokio::test] - async fn a_single_upload_declares_no_chunk_markers() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(3, 64)); - - let api_url = server.uri(); - let mut cap = captured(); - env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(uploaded_chunk_markers(&server).await, vec![(None, None)]); - // Nothing lost either: an ordinary run declares none of - // the three markers. - assert_eq!(uploaded_dropped_counts(&server).await, vec![None]); - } - - // A JUnit report of `n` failing cases, each with a - // `body_len`-byte incompressible body — the shape - // that forces the split path under a small cap. - fn incompressible_failures_xml(n: usize, body_len: usize) -> String { - use crate::testing::incompressible; - let mut xml = String::from("\n\n"); - xml.push_str(&format!( - " \n" - )); - for i in 0..n { - xml.push_str(&format!( - " \n\ - {body}\n\ - \n", - body = incompressible(i as u64, body_len), - )); - } - xml.push_str(" \n"); - xml - } - - async fn traces_requests(server: &MockServer) -> usize { - server - .received_requests() - .await - .unwrap() - .into_iter() - .filter(|r| r.url.path() == "/v1/repos/owner/repo/ci/traces") - .count() - } - - // Every case individually gzips past the (absurdly small) cap: - // the split drops them all, nothing reaches the backend, and - // that must be reported as NOT uploaded / status=failed — never - // as a success. The CI verdict is still driven by the failures. - #[tokio::test] - async fn all_cases_oversized_reports_nothing_uploaded() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(3, 512)); - let github_output = tmp.path().join("github_output"); - - let api_url = server.uri(); - let output_path = github_output.to_string_lossy().into_owned(); - let mut cap = captured(); - // 64-byte cap: smaller than any single case's gzipped span. - let code = env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_OUTPUT", Some(&output_path)), - ], - async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 64).await.unwrap() - }, - ) - .await; - - assert_eq!(code, ExitCode::GenericError); - // Nothing was POSTed to the traces endpoint. - assert_eq!(traces_requests(&server).await, 0); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!(stdout.contains("not uploaded"), "{stdout}"); - assert!(stdout.contains("too large to upload"), "{stdout}"); - let outputs = std::fs::read_to_string(&github_output).unwrap(); - assert!(outputs.contains("test_results_upload=failed"), "{outputs}"); - } - - // A transient failure on one chunk must not abandon the rest: - // with the traces endpoint always 503, every chunk is still - // attempted (continue-on-transient), not just the first. - #[tokio::test] - async fn transient_error_still_attempts_all_chunks() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 503).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(20, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }) - .await; - - // Verdict unaffected by the upload trouble. - assert_eq!(code, ExitCode::GenericError); - // All chunks attempted despite the first 503, not just one. - assert!( - traces_requests(&server).await > 1, - "a transient error must not abandon the remaining chunks" - ); - } - - // A permanent rejection (bad token) fails every chunk - // identically, so the loop must stop after the first rather - // than re-POST the whole fan-out. - #[tokio::test] - async fn rejection_stops_after_first_chunk() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 403).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", &incompressible_failures_xml(20, 2048)); - - let api_url = server.uri(); - let mut cap = captured(); - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run_with_cap(opts, &mut cap.output, 4 * 1024).await.unwrap() - }) - .await; - - assert_eq!(code, ExitCode::GenericError); - assert_eq!( - traces_requests(&server).await, - 1, - "a permanent rejection must stop after the first chunk" - ); - } - - #[tokio::test] - async fn unquarantined_failure_exits_one_with_fail_banner() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml( - &tmp, - "report.xml", - r#" - - - - - stack trace body - - -"#, - ); - - let api_url = server.uri(); - let mut cap = captured(); - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(code, ExitCode::GenericError); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - // Verdict line: 1 failing test, 0 quarantined. - assert!( - stdout.contains("❌ FAIL — 0/1 failures quarantined"), - "{stdout}" - ); - assert!(stdout.contains("Exit code: 1"), "{stdout}"); - } - - // Mount a quarantine mock that says "nothing quarantined" - // and a traces mock that answers `upload_status`. - async fn mount_mocks_with_upload_status(server: &MockServer, upload_status: u16) { - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "quarantined_tests": [], - }))) - .mount(server) - .await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .respond_with( - ResponseTemplate::new(upload_status).set_body_string("upload refused"), - ) - .mount(server) - .await; - } - - const ONE_PASSING_TEST_XML: &str = r#" - - - - -"#; - - async fn run_with_gha_env( - api_url: &str, - file: String, - github_output: &std::path::Path, - ) -> (ExitCode, String) { - let mut cap = captured(); - let output_path = github_output.to_string_lossy().into_owned(); - let code = env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_OUTPUT", Some(&output_path)), - ], - async { - let opts = JunitProcessOptions { - api_url: Some(api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }, - ) - .await; - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - (code, stdout) - } - - // Issue #1571: a rejected upload (e.g. token without ingest - // permission → 403) must stay visible without failing the - // run — upload trouble must never break customer CI, even - // when it's a permanent misconfiguration. On GitHub Actions - // the run surfaces it as an `::error::` annotation plus a - // `test_results_upload=rejected` step output so workflows - // can detect dead ingest programmatically. - #[tokio::test] - async fn rejected_upload_keeps_run_green_and_annotates() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 403).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", ONE_PASSING_TEST_XML); - let github_output = tmp.path().join("github_output"); - - let (code, stdout) = run_with_gha_env(&server.uri(), file, &github_output).await; - - assert_eq!(code, ExitCode::Success); - assert!(stdout.contains("✅ OK — all tests passed"), "{stdout}"); - assert!(stdout.contains("Exit code: 0"), "{stdout}"); - assert!( - stdout.contains("⚠️ Failed to upload test results"), - "{stdout}" - ); - assert!( - stdout.contains( - "::error title=Mergify Test Insights::Test results upload rejected (HTTP 403)" - ), - "{stdout}" - ); - let outputs = std::fs::read_to_string(&github_output).unwrap(); - assert!( - outputs.contains("test_results_upload=rejected"), - "{outputs}" - ); - } - - // Transient backend trouble (5xx, network) keeps the - // best-effort behavior too, but annotates as a warning and - // reports `test_results_upload=failed`. - #[tokio::test] - async fn transient_upload_error_keeps_run_green_and_warns() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 503).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", ONE_PASSING_TEST_XML); - let github_output = tmp.path().join("github_output"); - - let (code, stdout) = run_with_gha_env(&server.uri(), file, &github_output).await; - - assert_eq!(code, ExitCode::Success); - assert!(stdout.contains("✅ OK — all tests passed"), "{stdout}"); - assert!( - stdout.contains("⚠️ Failed to upload test results"), - "{stdout}" - ); - assert!( - stdout.contains("::warning title=Mergify Test Insights::Failed to upload"), - "{stdout}" - ); - let outputs = std::fs::read_to_string(&github_output).unwrap(); - assert!(outputs.contains("test_results_upload=failed"), "{outputs}"); - } - - // A successful upload reports `test_results_upload=success` - // so workflows get a stable key to branch on, and emits no - // annotation. - #[tokio::test] - async fn successful_upload_writes_success_output_and_no_annotation() { - let server = MockServer::start().await; - mount_mocks(&server).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", ONE_PASSING_TEST_XML); - let github_output = tmp.path().join("github_output"); - - let (code, stdout) = run_with_gha_env(&server.uri(), file, &github_output).await; - - assert_eq!(code, ExitCode::Success); - assert!(!stdout.contains("::error"), "{stdout}"); - assert!(!stdout.contains("::warning"), "{stdout}"); - let outputs = std::fs::read_to_string(&github_output).unwrap(); - assert!(outputs.contains("test_results_upload=success"), "{outputs}"); - } - - // Outside GitHub Actions there is no workflow-command - // protocol — the report must stay free of `::error::` noise - // and no GITHUB_OUTPUT file appears. - #[tokio::test] - async fn rejected_upload_outside_gha_emits_no_annotation() { - let server = MockServer::start().await; - mount_mocks_with_upload_status(&server, 403).await; - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml(&tmp, "report.xml", ONE_PASSING_TEST_XML); - - let api_url = server.uri(); - let mut cap = captured(); - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some(&api_url), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(code, ExitCode::Success); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!( - stdout.contains("⚠️ Failed to upload test results"), - "{stdout}" - ); - assert!(!stdout.contains("::error"), "{stdout}"); - } - - #[tokio::test] - async fn testsuite_without_cases_short_circuits_with_no_test_cases_error() { - // A `` wrapping an empty `` is - // valid XML and gets past the parser (the parser only - // rejects bare `` with zero suites), but - // produces zero TestCase records. Because suites *were* - // parsed, the orchestrator must report "No test cases - // found" (the test step ran but emitted nothing), not - // the "No spans found" message reserved for files with - // no suites at all. - let tmp = tempfile::tempdir().unwrap(); - let file = write_xml( - &tmp, - "report.xml", - r#" - - -"#, - ); - - let mut cap = captured(); - // No mock server: if the orchestrator skips the early - // exit and tries to reach the API, the bogus URL will - // fail the test loudly. - let code = env::testing::with_no_vars_async(async { - let opts = JunitProcessOptions { - api_url: Some("http://127.0.0.1:1"), - token: Some("secret"), - repository: Some("owner/repo"), - test_framework: None, - test_language: None, - tests_target_branch: Some("main"), - test_exit_code: None, - files: &[file], - }; - run(opts, &mut cap.output).await.unwrap() - }) - .await; - - assert_eq!(code, ExitCode::GenericError); - let stdout = String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(); - assert!( - stdout.contains("No test cases found in the JUnit files"), - "{stdout}" - ); - assert!( - stdout.contains("Check that your test step ran successfully before this step."), - "{stdout}" - ); - assert!(!stdout.contains("No spans found"), "{stdout}"); - } - } -} diff --git a/crates/mergify-ci/src/junit_process/junit.rs b/crates/mergify-ci/src/junit_process/junit.rs deleted file mode 100644 index b34423c3..00000000 --- a/crates/mergify-ci/src/junit_process/junit.rs +++ /dev/null @@ -1,912 +0,0 @@ -//! `JUnit` XML → semantically-tagged test cases. -//! -//! The parser accepts the loose dialect every `JUnit` producer in -//! the wild emits: `` root with nested `` -//! children, a bare `` root, or a `` root -//! that itself has nested `` descendants. Within each -//! suite, every `` becomes a [`TestCase`] tagged with -//! its result (pass / skip / fail / error) plus optional -//! exception attributes pulled from a `` or `` -//! child. -//! -//! This module owns the data model only. Converting [`TestCase`] -//! to OTLP spans + uploading them is `super::upload`'s job. - -use std::time::Duration; - -use mergify_core::CliError; -use quick_xml::events::Event; -use quick_xml::name::QName; -use quick_xml::reader::Reader; -use serde::Serialize; - -/// The four states a `JUnit` `` can be in. Drives the -/// `test.case.result.status` attribute on each emitted span. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum TestStatus { - Passed, - Skipped, - Failed, - Errored, -} - -impl TestStatus { - /// Wire value for `test.case.result.status`. Two of the four - /// collapse: `` is rendered as `"failed"` because the - /// backend treats them identically. The Rust enum keeps them - /// distinct so the UI layer can show different copy. - #[must_use] - pub fn status_attr(self) -> &'static str { - match self { - Self::Passed => "passed", - Self::Skipped => "skipped", - Self::Failed | Self::Errored => "failed", - } - } - - /// True for `Failed` and `Errored` — the cases the quarantine - /// API has anything to say about. - #[must_use] - pub fn is_failure(self) -> bool { - matches!(self, Self::Failed | Self::Errored) - } -} - -/// Exception-style metadata pulled from a `` / -/// `` element. Each field is optional because the `JUnit` -/// "spec" doesn't really exist — most frameworks emit some -/// subset. -/// -/// Field names map to the OpenTelemetry `exception.*` attribute -/// keys the Python span builder reads — `kind` → `exception.type`, -/// `message` → `exception.message`, `stacktrace` → -/// `exception.stacktrace`. The serialized JSON keeps the -/// Rust-side names so the bridge has a single canonical shape. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct Failure { - /// `type` XML attribute (e.g. `AssertionError`, - /// `java.lang.RuntimeException`). - pub kind: Option, - /// `message` XML attribute (e.g. `assert 1 == 0`). - pub message: Option, - /// Body text of the element — typically the stack trace. - /// Trimmed of surrounding whitespace. - pub stacktrace: Option, -} - -/// A single test case from a `JUnit` XML file, after parsing. -/// -/// `name` is `"."` when `classname` is present, -/// or just `` otherwise — same composition Python uses. -/// The duration may be zero when the framework omitted a -/// `time=` attribute. -/// -/// `duration` serializes as `Option` (seconds) — `null` when -/// the framework omitted `time=`, otherwise the floating-point -/// number of seconds. Python's bridge decodes this directly into -/// the existing `time` value the span builder used to read from -/// the XML attribute. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct TestCase { - /// Fully-qualified test name: `"."` or - /// `""` when classname is absent. - pub name: String, - /// Name of the parent ``. Mirrors Python's - /// `suite_name` derived from the suite's `name=` attribute, - /// defaulting to `"unnamed testsuite"`. - pub suite_name: String, - /// Self-reported run duration. `None` when the framework - /// omitted the `time=` attribute; the upload layer translates - /// `None` into a zero start-time offset. - #[serde(serialize_with = "serialize_duration_secs")] - pub duration: Option, - /// `file=` attribute, when set. Becomes the - /// `code.filepath` span attribute. - pub file: Option, - /// `line=` attribute, when set. Becomes the `code.lineno` - /// span attribute. - pub line: Option, - pub status: TestStatus, - /// First `` / `` child's metadata. Empty for - /// passing and skipped cases; matches Python's - /// "we only care about the first failure/error" branch. - pub failure: Failure, -} - -// Serde's `serialize_with` callback signature forces `&Option` -// here, even though the function body could equivalently take -// `Option<&T>`. Suppress the lint for this one call site rather -// than restructuring the upstream contract. -#[allow(clippy::ref_option)] -fn serialize_duration_secs(duration: &Option, serializer: S) -> Result -where - S: serde::Serializer, -{ - match duration { - Some(d) => serializer.serialize_some(&d.as_secs_f64()), - None => serializer.serialize_none(), - } -} - -/// Top-level parse result. `suites` is a flat list of suite -/// names (in document order, deduplicated by index) for diagnostic -/// printing; the actual span hierarchy is reconstructed in the -/// upload layer using `TestCase::suite_name`. -#[derive(Debug, Clone, Serialize)] -pub struct ParseResult { - /// Testsuite names in document order — first occurrence wins - /// for nested suites that share a name. This is what the - /// upload layer iterates over to build one suite span per - /// entry; deriving suite order from `cases` doesn't work - /// because a nested suite's cases appear in the case stream - /// before the parent suite's *direct* cases, even though the - /// parent's `` opens first. - pub suite_names: Vec, - pub cases: Vec, -} - -#[derive(Debug, Clone)] -pub struct InvalidJunitXml { - pub details: String, -} - -impl std::fmt::Display for InvalidJunitXml { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Failed to parse JUnit XML: {details}", - details = self.details - ) - } -} - -impl std::error::Error for InvalidJunitXml {} - -impl From for CliError { - fn from(err: InvalidJunitXml) -> Self { - // Preserve the typed error as a transparent source instead of - // flattening it to a string (same Display, stays downcastable). - Self::Source(Box::new(err)) - } -} - -/// Parse a `JUnit` XML document into a flat list of [`TestCase`]s. -/// -/// Accepts the same shapes Python does: -/// - `…` (most common — pytest, `JUnit` 5) -/// - bare `` root -/// - `` root with nested `` descendants -/// -/// Anything else (different root tag, or `` / -/// `` with no nested suites at all) is rejected as -/// "no testsuites or testsuite tag found" — same message Python -/// raises. -/// -/// # Errors -/// -/// [`InvalidJunitXml`] on XML parse failure or unsupported root -/// shape. Surface this as [`CliError::Generic`] at the CLI -/// boundary (the `From` impl above does the conversion). -pub fn parse(xml: &[u8]) -> Result { - let mut reader = Reader::from_reader(xml); - // Intentionally do NOT enable `trim_text(true)`. quick-xml - // emits a fresh Text event after every entity reference; with - // trimming on, the whitespace between `…` and the next `>` - // gets stripped, producing concatenated runs like `"->None:"` - // instead of `"-> None:"`. We only collect text while inside a - // `` / `` element (see `append_failure_text`), - // so stray inter-tag whitespace elsewhere is harmless. - - let mut state = ParserState::default(); - let mut buf: Vec = Vec::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) => state.on_start(&e)?, - Ok(Event::End(e)) => state.on_end(&e), - Ok(Event::Empty(e)) => { - // An empty element (``) is a - // Start + immediate End at the parser level. The - // state machine handles that as "open + close in - // one step": don't push a stack frame. - state.on_empty(&e)?; - } - Ok(Event::Text(e)) => { - // In quick-xml 0.40 the parser splits text around - // entities — `Text` events contain literal runs only, - // and each `&name;` arrives as a separate - // `GeneralRef` event below; entity resolution - // happens in the `GeneralRef` arm. - // - // `xml10_content()` (vs the plain `Deref` content) is important on Windows: when git - // checks fixtures out with `core.autocrlf` enabled - // the file's line endings are `\r\n`, and the XML - // 1.0 spec requires those to be normalized to `\n` - // before they reach element text. Without this, - // failure stacktraces ship `\r\n` and tests that - // diff on the assembled stacktrace fail only on - // Windows. - let s = e.xml10_content(); - state.append_failure_text(&s); - } - Ok(Event::CData(e)) => { - // quick-xml validates UTF-8 up front, so the event - // already derefs to `str` — no decoding step needed. - state.append_failure_text(e.as_ref()); - } - Ok(Event::GeneralRef(e)) => { - // quick-xml emits a separate `GeneralRef` event for - // each `&name;` reference. The predefined XML - // entities we handle (`lt`, `gt`, `amp`, `apos`, - // `quot`) cover pytest-junit output; numeric - // character references (`&#NN;` / `&#xNN;`) resolve - // via `resolve_char_ref`. Unknown DTD entities - // aren't emitted by `JUnit` producers we care about — - // fall back to the literal `&name;` so we don't - // silently drop content. - if e.is_char_ref() { - if let Some(ch) = e.resolve_char_ref().map_err(|err| InvalidJunitXml { - details: format!("invalid character reference: {err}"), - })? { - let mut tmp = [0u8; 4]; - state.append_failure_text(ch.encode_utf8(&mut tmp)); - } - } else { - let name = e.as_ref(); - let resolved = match name { - "lt" => "<", - "gt" => ">", - "amp" => "&", - "apos" => "'", - "quot" => "\"", - _ => "", - }; - if resolved.is_empty() { - state.append_failure_text(&format!("&{name};")); - } else { - state.append_failure_text(resolved); - } - } - } - Ok(Event::Eof) => break, - Err(e) => { - return Err(InvalidJunitXml { - details: e.to_string(), - }); - } - _ => {} - } - buf.clear(); - } - - state.finalize() -} - -/// Local tag of an XML name. quick-xml gives us either -/// `testsuite` or `ns:testsuite` (XML namespaces); Python's -/// `findall(".//{*}testsuite")` ignores the namespace prefix -/// entirely, so we do the same. -fn local_name<'a>(name: QName<'a>) -> &'a str { - // `QName::local_name` would return a wrapped type; we want the - // raw string here. `into_inner()` gives the input-borrowed - // `&'a str` back so the return stays tied to `'a`, not the - // temporary function frame. - let raw: &'a str = name.into_inner(); - raw.rsplit(':').next().unwrap_or(raw) -} - -/// Decode an attribute value to a `String`, resolving XML entity -/// escapes (`&`, `<`, …). The newer `decode_and_unescape` -/// API takes a `&Reader` so it can apply non-UTF-8 transcoding; -/// `JUnit` XML is UTF-8 in every emitter we care about, so the -/// `unescape_value` shortcut (deprecated for that encoding -/// concern, not for behavior) is the right tool here. -#[allow(deprecated)] -fn attr_value( - attr: &quick_xml::events::attributes::Attribute<'_>, -) -> Result { - attr.unescape_value() - .map(std::borrow::Cow::into_owned) - .map_err(|e| InvalidJunitXml { - details: format!("invalid attribute value: {e}"), - }) -} - -#[derive(Default)] -#[allow(clippy::struct_excessive_bools)] // four small flags model the JUnit parser's hand-rolled -// state machine more clearly than packing them behind a single enum / bitflags would. -struct ParserState { - /// True once we've seen the root and confirmed it's - /// `` or ``. Anything else gets - /// rejected before any cases land. - saw_valid_root: bool, - /// Stack of currently-open `` names. The - /// innermost one is the "current suite" for any `` - /// we encounter. We allow nested suites because Python does - /// (it does `findall(".//{*}testsuite")`). - suite_stack: Vec, - /// True while we're inside a ``; `in_progress` holds - /// the partially-built case. - in_progress: Option, - /// True while we're inside the first `` / `` - /// child of the current testcase — we use this to attribute - /// text content (the stack trace) to the right place. Python - /// only keeps the FIRST failure/error per case, so once we've - /// captured one we stop collecting further metadata. - in_failure: bool, - /// Accumulator for the current failure/error element body. - /// quick-xml emits a *separate* `Text` / `GeneralRef` / - /// `CData` event each time it hits an entity boundary, so we - /// can't just overwrite a `stacktrace` field per event — - /// we'd lose everything before the last entity. Append here, - /// trim once at the closing tag. - failure_text_buf: String, - /// Did we already record a failure / error for the current - /// testcase? If so, ignore further `` / `` - /// siblings (Python's `break` after the first). - failure_captured: bool, - /// True once we've seen at least one `` element. - /// Used by `finalize` to enforce Python's "no testsuites or - /// testsuite tag found" error when neither is present. - saw_any_testsuite: bool, - /// Suite names in the order their `` opened in the - /// document. Mirrors Python's `findall(".//{*}testsuite")` - /// which yields ancestors before descendants — relevant when - /// a nested suite's cases appear in the case stream before the - /// outer suite's direct cases, but the outer suite's span must - /// still be emitted first. - suite_names: Vec, - output: Vec, -} - -impl ParserState { - /// Append a chunk of failure-body text. Called for every - /// `Text`, `CData`, and `GeneralRef` event quick-xml emits - /// between the opening `` / `` and its - /// matching close. - fn append_failure_text(&mut self, chunk: &str) { - if self.in_failure { - self.failure_text_buf.push_str(chunk); - } - } - - fn on_start(&mut self, e: &quick_xml::events::BytesStart<'_>) -> Result<(), InvalidJunitXml> { - let name = local_name(e.name()); - if !self.saw_valid_root { - match name { - "testsuites" => { - self.saw_valid_root = true; - return Ok(()); - } - "testsuite" => { - self.saw_valid_root = true; - self.saw_any_testsuite = true; - let suite_name = read_suite_name(e)?; - self.suite_names.push(suite_name.clone()); - self.suite_stack.push(suite_name); - return Ok(()); - } - _ => { - return Err(InvalidJunitXml { - details: "no testsuites or testsuite tag found".to_string(), - }); - } - } - } - - match name { - "testsuite" => { - self.saw_any_testsuite = true; - let suite_name = read_suite_name(e)?; - self.suite_names.push(suite_name.clone()); - self.suite_stack.push(suite_name); - } - "testcase" => { - if self.in_progress.is_some() { - return Err(InvalidJunitXml { - details: "nested not allowed".to_string(), - }); - } - let suite_name = self - .suite_stack - .last() - .cloned() - .unwrap_or_else(|| "unnamed testsuite".to_string()); - self.in_progress = Some(read_testcase(e, &suite_name)?); - self.failure_captured = false; - } - "failure" | "error" => { - if let Some(tc) = self.in_progress.as_mut() - && !self.failure_captured - { - tc.status = if name == "failure" { - TestStatus::Failed - } else { - TestStatus::Errored - }; - tc.failure = read_failure(e)?; - self.in_failure = true; - self.failure_captured = true; - self.failure_text_buf.clear(); - } - } - "skipped" => { - if let Some(tc) = self.in_progress.as_mut() { - // `` wins over `` only when - // the failure wasn't already recorded — Python - // checks skipped FIRST in its if-elif chain, - // so we mirror that by setting status only if - // we haven't captured a failure yet. - if !self.failure_captured { - tc.status = TestStatus::Skipped; - } - } - } - _ => {} - } - Ok(()) - } - - fn on_empty(&mut self, e: &quick_xml::events::BytesStart<'_>) -> Result<(), InvalidJunitXml> { - let name = local_name(e.name()); - // An empty element is identical to ; treat as - // open+close pair without bookkeeping the stack frame. - // For `` / `` / `` (rare but - // valid — they appear as plain markers without text), we - // mirror the work `on_start` does and just skip the - // matching `on_end` since there's no body to track. - match name { - "testsuite" => { - self.saw_any_testsuite = true; - if !self.saw_valid_root { - self.saw_valid_root = true; - } - // `` self-closing — record - // the name in document order so the upload layer - // emits a (case-less) suite span for it, matching - // Python's `findall(".//{*}testsuite")` walk. - self.suite_names.push(read_suite_name(e)?); - // Empty testsuite contributes nothing to `cases`. - } - "testcase" => { - if !self.saw_valid_root { - return Err(InvalidJunitXml { - details: " outside ".to_string(), - }); - } - let suite_name = self - .suite_stack - .last() - .cloned() - .unwrap_or_else(|| "unnamed testsuite".to_string()); - let tc = read_testcase(e, &suite_name)?; - self.output.push(tc); - } - "skipped" => { - if let Some(tc) = self.in_progress.as_mut() - && !self.failure_captured - { - tc.status = TestStatus::Skipped; - } - } - "failure" | "error" => { - if let Some(tc) = self.in_progress.as_mut() - && !self.failure_captured - { - tc.status = if name == "failure" { - TestStatus::Failed - } else { - TestStatus::Errored - }; - tc.failure = read_failure(e)?; - self.failure_captured = true; - } - } - _ if !self.saw_valid_root => { - return Err(InvalidJunitXml { - details: "no testsuites or testsuite tag found".to_string(), - }); - } - _ => {} - } - Ok(()) - } - - fn on_end(&mut self, e: &quick_xml::events::BytesEnd<'_>) { - let name = local_name(e.name()); - match name { - "testsuite" => { - self.suite_stack.pop(); - } - "testcase" => { - if let Some(tc) = self.in_progress.take() { - self.output.push(tc); - } - self.failure_captured = false; - } - "failure" | "error" if self.in_failure => { - // Flush the accumulated body text into the - // testcase's failure record. Trimming once at the - // close keeps the wire format identical to - // Python's `(child.text or "").strip()`. - let trimmed = self.failure_text_buf.trim(); - if !trimmed.is_empty() - && let Some(tc) = self.in_progress.as_mut() - { - tc.failure.stacktrace = Some(trimmed.to_string()); - } - self.failure_text_buf.clear(); - self.in_failure = false; - } - _ => {} - } - } - - fn finalize(self) -> Result { - if !self.saw_valid_root || !self.saw_any_testsuite { - return Err(InvalidJunitXml { - details: "no testsuites or testsuite tag found".to_string(), - }); - } - Ok(ParseResult { - suite_names: self.suite_names, - cases: self.output, - }) - } -} - -fn read_suite_name(e: &quick_xml::events::BytesStart<'_>) -> Result { - for attr in e.attributes() { - let attr = attr.map_err(|err| InvalidJunitXml { - details: format!("invalid attribute: {err}"), - })?; - if local_name(attr.key) == "name" { - return attr_value(&attr); - } - } - Ok("unnamed testsuite".to_string()) -} - -fn read_testcase( - e: &quick_xml::events::BytesStart<'_>, - suite_name: &str, -) -> Result { - let mut classname: Option = None; - let mut name: Option = None; - let mut time: Option = None; - let mut file: Option = None; - let mut line: Option = None; - for attr in e.attributes() { - let attr = attr.map_err(|err| InvalidJunitXml { - details: format!("invalid attribute: {err}"), - })?; - match local_name(attr.key) { - "classname" => classname = Some(attr_value(&attr)?), - "name" => name = Some(attr_value(&attr)?), - "time" => { - let raw = attr_value(&attr)?; - if !raw.is_empty() { - time = raw.parse::().ok(); - } - } - "file" => file = Some(attr_value(&attr)?), - "line" => line = Some(attr_value(&attr)?), - _ => {} - } - } - let name = name.unwrap_or_else(|| "unnamed test".to_string()); - let full_name = match classname { - Some(c) if !c.is_empty() => format!("{c}.{name}"), - _ => name, - }; - Ok(TestCase { - name: full_name, - suite_name: suite_name.to_string(), - duration: time.map(Duration::from_secs_f64), - file, - line, - // Default to "passed" — `` / `` / - // `` children update it on the way in. - status: TestStatus::Passed, - failure: Failure::default(), - }) -} - -fn read_failure(e: &quick_xml::events::BytesStart<'_>) -> Result { - let mut failure = Failure::default(); - for attr in e.attributes() { - let attr = attr.map_err(|err| InvalidJunitXml { - details: format!("invalid attribute: {err}"), - })?; - match local_name(attr.key) { - "type" => failure.kind = Some(attr_value(&attr)?), - "message" => failure.message = Some(attr_value(&attr)?), - _ => {} - } - } - Ok(failure) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse_str(s: &str) -> ParseResult { - parse(s.as_bytes()).expect("xml parses") - } - - #[test] - fn rejects_unknown_root() { - // Anything other than `` or `` at - // the root is the same error Python raises: - // "no testsuites or testsuite tag found". - let err = parse(b"").unwrap_err(); - assert!( - err.details.contains("no testsuites or testsuite tag found"), - "got: {}", - err.details, - ); - } - - #[test] - fn rejects_empty_xml() { - let err = parse(b"").unwrap_err(); - assert!( - err.details.contains("no testsuites or testsuite tag found"), - "got: {}", - err.details, - ); - } - - #[test] - fn rejects_malformed_xml() { - // Truncated/invalid XML must surface as `InvalidJunitXml`, - // not panic. - let err = parse(b"`, one suite, one pass + one fail with - // `` carrying a message attribute and body text. - let r = parse_str( - r#" - - - - - stack trace body - - -"#, - ); - assert_eq!(r.cases.len(), 2); - assert_eq!(r.cases[0].name, "tests.test_func.test_success"); - assert_eq!(r.cases[0].status, TestStatus::Passed); - assert!(r.cases[0].failure.message.is_none()); - - assert_eq!(r.cases[1].name, "tests.test_func.test_failed"); - assert_eq!(r.cases[1].status, TestStatus::Failed); - assert_eq!(r.cases[1].failure.message.as_deref(), Some("assert 1 == 0")); - assert_eq!( - r.cases[1].failure.stacktrace.as_deref(), - Some("stack trace body") - ); - // Duration extracted from `time="0.012"`. - assert_eq!(r.cases[1].duration, Some(Duration::from_secs_f64(0.012))); - } - - #[test] - fn parses_bare_testsuite_root() { - // Some frameworks emit `` as the root directly. - // Python supports that via the - // `root.tag == "testsuite"` branch — we must too. - let r = parse_str( - r#" - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].name, "C.t"); - assert_eq!(r.cases[0].suite_name, "solo"); - } - - #[test] - fn parses_namespaced_elements() { - // Some emitters (older Maven Surefire) wrap everything in - // an XML namespace. Python ignores the prefix via - // `{*}testsuite`; we must do the same. - let r = parse_str( - r#" - - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].name, "C.t"); - } - - #[test] - fn skipped_status_propagates() { - let r = parse_str( - r#" - - - - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].status, TestStatus::Skipped); - assert_eq!(r.cases[0].status.status_attr(), "skipped"); - } - - #[test] - fn error_distinct_from_failure_but_same_wire_attr() { - // `` and `` are different XML elements but - // both render as `status=failed` on the wire (matches - // Python's branch). The Rust enum keeps them distinct so - // future UI can show different copy if needed. - let r = parse_str( - r#" - - - stack - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].status, TestStatus::Errored); - assert_eq!(r.cases[0].status.status_attr(), "failed"); - assert_eq!(r.cases[0].failure.kind.as_deref(), Some("RuntimeError")); - assert_eq!(r.cases[0].failure.message.as_deref(), Some("boom")); - } - - #[test] - fn only_first_failure_kept() { - // Mirrors Python's `break` after the first failure/error - // — multiple `` siblings keep only the first - // one's metadata. - let r = parse_str( - r#" - - - trace1 - trace2 - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].failure.message.as_deref(), Some("first")); - assert_eq!(r.cases[0].failure.stacktrace.as_deref(), Some("trace1")); - } - - #[test] - fn cdata_stacktrace_captured() { - // Stack traces commonly come through as `` - // to avoid escaping. The CDATA event handler must - // populate `failure.stacktrace` the same way as plain text. - let r = parse_str( - r#" - - - - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!( - r.cases[0].failure.stacktrace.as_deref(), - Some("multi\nline trace"), - ); - } - - #[test] - fn testcase_without_classname_uses_bare_name() { - let r = parse_str( - r#" - - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].name, "raw_test"); - } - - #[test] - fn testsuite_without_name_uses_default() { - let r = parse_str( - r#" - - - -"#, - ); - assert_eq!(r.cases[0].suite_name, "unnamed testsuite"); - } - - #[test] - fn nested_testsuites_flatten_to_cases() { - // A `` root with descendant `` — - // shape Python supports (`testsuites = [root, *root.findall(".//{*}testsuite")]`). - // Cases under nested suites must still surface, tagged - // with the *closest* enclosing suite name. - let r = parse_str( - r#" - - - -"#, - ); - assert_eq!(r.cases.len(), 1); - assert_eq!(r.cases[0].suite_name, "inner"); - assert_eq!(r.cases[0].name, "C.nested"); - } - - #[test] - fn parses_live_smoke_fixture() { - // The fixture used by the `test_junit_process` live smoke - // test — one passing case, one failing case with a - // multi-line `` body. If the parser drifts from - // what the smoke fixture looks like, the upload layer - // would silently miss cases or attach the wrong attributes - // to the wrong span. Pin the shape end-to-end. - const FIXTURE: &str = r#" - - - - - def test_failed() -> None: - > assert 1 == 0 - E assert 1 == 0 - - tests/test_func.py:6: AssertionError - - - -"#; - let r = parse_str(FIXTURE); - assert_eq!(r.cases.len(), 2); - let pass = &r.cases[0]; - let fail = &r.cases[1]; - assert_eq!(pass.name, "tests.test_func.test_success"); - assert_eq!(pass.status, TestStatus::Passed); - assert_eq!(fail.name, "tests.test_func.test_failed"); - assert_eq!(fail.status, TestStatus::Failed); - // `>` entity must have been unescaped to `>` — that's - // the whole point of routing the body through - // `unescape_value`. - let stack = fail.failure.stacktrace.as_deref().unwrap_or(""); - assert!(stack.contains("> assert 1 == 0"), "got: {stack:?}"); - assert!(stack.contains("AssertionError"), "got: {stack:?}"); - assert_eq!(fail.failure.message.as_deref(), Some("assert 1 == 0")); - } - - #[test] - fn empty_testsuites_rejected() { - // `` alone has no suites. Python raises - // "no testsuites or testsuite tag found" via the - // `if not testsuites:` check after expansion. We mirror - // that. - let err = parse(b"").unwrap_err(); - assert!( - err.details.contains("no testsuites or testsuite tag found"), - "got: {}", - err.details, - ); - } -} diff --git a/crates/mergify-ci/src/junit_process/mod.rs b/crates/mergify-ci/src/junit_process/mod.rs deleted file mode 100644 index 587abb07..00000000 --- a/crates/mergify-ci/src/junit_process/mod.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! `mergify ci junit-process` — `JUnit` XML report → OTLP trace -//! upload + quarantine check. -//! -//! Layers: -//! -//! - [`junit`]: `JUnit` XML parser producing semantically-tagged -//! [`TestCase`] values. Hermetic, no network. -//! - [`spans`]: turns parser output into an OTLP -//! `ExportTraceServiceRequest`. -//! - [`split`]: partitions an oversized request into several uploads -//! that each gzip under [`upload::MAX_GZIPPED_UPLOAD_BYTES`]. -//! - [`upload`]: gzips that protobuf payload and POSTs it to -//! `/v1/repos///ci/traces`. -//! - [`quarantine`]: queries the quarantine API to learn which -//! failing tests are currently quarantined. -//! - [`command::run`]: orchestrates everything and renders the -//! human-facing report. - -pub mod command; -pub mod junit; -pub mod quarantine; -pub mod spans; -pub mod split; -pub mod upload; - -pub use command::{JunitProcessOptions, run}; -pub use junit::{Failure, InvalidJunitXml, ParseResult, TestCase, TestStatus}; -pub use quarantine::{QuarantineFailed, QuarantineResult}; -pub use spans::{BuiltTraces, UploadMetadata, build_traces}; -pub use split::{Chunk, SplitOutcome, split_request}; -pub use upload::{ - MAX_GZIPPED_UPLOAD_BYTES, UploadError, default_client, upload, upload_compressed, -}; diff --git a/crates/mergify-ci/src/junit_process/quarantine.rs b/crates/mergify-ci/src/junit_process/quarantine.rs deleted file mode 100644 index 91908603..00000000 --- a/crates/mergify-ci/src/junit_process/quarantine.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! Quarantine API client for `mergify ci junit-process`. -//! -//! The API answers "for these failing tests on this branch, which -//! are currently quarantined?" — failures of quarantined tests -//! are ignored by the final CI verdict; failures of non-quarantined -//! tests still block. -//! -//! Endpoint shape: -//! `GET {api_url}/v1/ci/{owner}/repositories/{repo}/quarantines?branch=...` -//! returns, one cursor-paginated page at a time, -//! ```json -//! { "quarantined_tests": [{ "test_name": "..." }, ...] } -//! ``` - -use std::collections::BTreeSet; - -use mergify_core::{ApiFlavor, CliError, HttpClient}; -use serde::Deserialize; -use url::Url; - -use crate::detector; -use crate::junit_process::junit::TestCase; -use crate::tests_quarantine::QuarantineList; - -/// Page size requested from the quarantine list endpoint. -const PER_PAGE: &str = "100"; - -/// Status the API answers when quarantine is not in the plan. -const PAYMENT_REQUIRED: u16 = 402; - -/// Cross-cutting view of a `junit-process` run: which case names -/// failed, which the backend says are currently quarantined, and -/// which are not. Drives the OTLP attribute tagging (a failing -/// case in the quarantined set gets `cicd.test.quarantined = -/// true`) as well as the CLI verdict (a non-zero count of -/// non-quarantined failures means the run fails). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct QuarantineResult { - /// Every failing test case (status = Failed or Errored). - pub failing: Vec, - /// Subset of `failing` currently quarantined on the branch. - pub quarantined: Vec, - /// Subset of `failing` not quarantined on the branch. Empty → - /// CI passes, non-empty → CI fails. - pub non_quarantined: Vec, -} - -#[derive(Debug, Clone)] -pub struct QuarantineFailed { - pub message: String, -} - -impl std::fmt::Display for QuarantineFailed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -impl std::error::Error for QuarantineFailed {} - -/// Find every test case in `cases` whose status is a failure -/// (`Failed` or `Errored`). Mirrors Python's filter; the spans -/// inheriting this property are tagged `cicd.test.quarantined` -/// at the `spans` layer based on the result of [`fetch`]. -fn failing_cases(cases: &[TestCase]) -> Vec { - cases - .iter() - .filter(|c| c.status.is_failure()) - .cloned() - .collect() -} - -/// Fetch the names of every test quarantined on `branch`, following -/// the cursor pagination until the last page. A repository without -/// the feature in its plan (402) has nothing quarantined. -pub async fn fetch( - api_url: &Url, - token: &str, - repository: &str, - branch: &str, -) -> Result, QuarantineFailed> { - let failed = |message: String| QuarantineFailed { message }; - let (owner, repo) = - detector::split_owner_repo(repository).map_err(|e| failed(e.to_string()))?; - let client = HttpClient::new(api_url.clone(), token, ApiFlavor::Mergify) - .map_err(|e| failed(e.to_string()))?; - - let path = format!("/v1/ci/{owner}/repositories/{repo}/quarantines"); - let mut quarantined = BTreeSet::new(); - let mut seen_cursors = BTreeSet::new(); - let mut cursor: Option = None; - loop { - let mut query = vec![("branch", branch), ("per_page", PER_PAGE)]; - if let Some(cursor) = &cursor { - query.push(("cursor", cursor)); - } - let Some(page) = client - .get_page_unless::>(&path, &query, PAYMENT_REQUIRED) - .await - .map_err(|e| failed(e.to_string()))? - else { - return Ok(BTreeSet::new()); - }; - quarantined.extend(page.body.quarantined_tests.into_iter().map(|t| t.test_name)); - - let Some(next) = page.next_cursor else { - return Ok(quarantined); - }; - // A cursor pointing back to a fetched page would loop forever; - // a partial list would silently un-quarantine tests. - if !seen_cursors.insert(next.clone()) { - return Err(failed( - "quarantine pagination cycled back to a fetched page".to_string(), - )); - } - cursor = Some(next); - } -} - -/// Categorize the failing test cases into quarantined and -/// non-quarantined buckets, given the branch's quarantined names. The result -/// keeps the failing-cases list intact so the CLI can render the -/// "X/Y failures quarantined" summary without re-walking the -/// original `JUnit` input. -#[must_use] -pub fn categorize( - failing: Vec, - quarantined_names: &BTreeSet, -) -> QuarantineResult { - let mut quarantined = Vec::new(); - let mut non_quarantined = Vec::new(); - - for case in &failing { - if quarantined_names.contains(&case.name) { - quarantined.push(case.clone()); - } else { - non_quarantined.push(case.clone()); - } - } - - QuarantineResult { - failing, - quarantined, - non_quarantined, - } -} - -/// Resolve the failing test cases for `cases`, hit the quarantine -/// API, and combine the two into a [`QuarantineResult`]. The CLI -/// orchestration uses the bundled fn so the happy path is a single -/// call instead of three. -pub async fn check_failing( - api_url: &Url, - token: &str, - repository: &str, - branch: &str, - cases: &[TestCase], -) -> Result { - let failing = failing_cases(cases); - if failing.is_empty() { - return Ok(QuarantineResult::default()); - } - let quarantined_names = fetch(api_url, token, repository, branch).await?; - Ok(categorize(failing, &quarantined_names)) -} - -/// Lift a [`QuarantineFailed`] into the shared [`CliError`] so -/// callers can `?` it. -impl From for CliError { - fn from(err: QuarantineFailed) -> Self { - // Preserve the typed error as a transparent source instead of - // flattening it (same Display, stays downcastable). - Self::Source(Box::new(err)) - } -} - -#[derive(Deserialize)] -struct QuarantinedTestName { - test_name: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::junit_process::junit::{Failure, TestStatus}; - use std::time::Duration; - use wiremock::matchers::{header, method, path, query_param, query_param_is_missing}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn case(name: &str, status: TestStatus) -> TestCase { - TestCase { - name: name.to_string(), - suite_name: "s".to_string(), - duration: Some(Duration::from_secs(0)), - file: None, - line: None, - status, - failure: Failure::default(), - } - } - - fn names(cases: &[TestCase]) -> Vec<&str> { - cases.iter().map(|c| c.name.as_str()).collect() - } - - fn page(test_names: &[&str]) -> serde_json::Value { - let tests: Vec<_> = test_names - .iter() - .map(|name| serde_json::json!({ "test_name": name })) - .collect(); - serde_json::json!({ "quarantined_tests": tests }) - } - - fn link(cursor: &str) -> String { - format!( - "; rel=\"next\"" - ) - } - - #[test] - fn categorize_buckets_quarantined_separately() { - let failing = vec![ - case("a", TestStatus::Failed), - case("b", TestStatus::Errored), - case("c", TestStatus::Failed), - ]; - let quarantined_names = ["a".to_string(), "unrelated".to_string()] - .into_iter() - .collect(); - let r = categorize(failing, &quarantined_names); - assert_eq!(names(&r.quarantined), vec!["a"]); - assert_eq!(names(&r.non_quarantined), vec!["b", "c"]); - // 2 failures not quarantined — drives the non-zero exit code. - assert_eq!(r.non_quarantined.len(), 2); - } - - #[tokio::test] - async fn fetch_follows_pagination_to_completion() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .and(header("Authorization", "Bearer secret")) - .and(query_param("branch", "main")) - .and(query_param("per_page", "100")) - .and(query_param_is_missing("cursor")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", link("c2").as_str()) - .set_body_json(page(&["t1"])), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .and(query_param("branch", "main")) - .and(query_param("per_page", "100")) - .and(query_param("cursor", "c2")) - .respond_with(ResponseTemplate::new(200).set_body_json(page(&["t2"]))) - .expect(1) - .mount(&server) - .await; - - let api_url = Url::parse(&server.uri()).unwrap(); - let quarantined = fetch(&api_url, "secret", "owner/repo", "main") - .await - .expect("API call succeeds"); - assert_eq!( - quarantined, - ["t1".to_string(), "t2".to_string()].into_iter().collect() - ); - } - - #[tokio::test] - async fn fetch_surfaces_pagination_cycle_as_quarantine_failed() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", link("loop").as_str()) - .set_body_json(page(&["t1"])), - ) - .expect(2) - .mount(&server) - .await; - - let api_url = Url::parse(&server.uri()).unwrap(); - let err = fetch(&api_url, "tok", "owner/repo", "main") - .await - .expect_err("a cycle must not return a partial list"); - assert_eq!( - err.message, - "quarantine pagination cycled back to a fetched page" - ); - } - - #[tokio::test] - async fn fetch_treats_payment_required_as_nothing_quarantined() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/ci/owner/repositories/repo/quarantines")) - .respond_with(ResponseTemplate::new(402).set_body_string("upgrade your plan")) - .expect(1) - .mount(&server) - .await; - - let api_url = Url::parse(&server.uri()).unwrap(); - let quarantined = fetch(&api_url, "tok", "owner/repo", "main") - .await - .expect("402 is not a failure"); - assert!(quarantined.is_empty()); - } - - #[tokio::test] - async fn check_failing_short_circuits_when_no_failures() { - // Empty failing list → no HTTP call, no QuarantineResult to - // categorize. If the function accidentally tried to fetch, - // the bogus URL would fail. - let api_url = Url::parse("http://127.0.0.1:1").unwrap(); - let cases = vec![ - case("ok", TestStatus::Passed), - case("skipped", TestStatus::Skipped), - ]; - let r = check_failing(&api_url, "tok", "owner/repo", "main", &cases) - .await - .expect("must short-circuit"); - assert!(r.failing.is_empty()); - assert!(r.non_quarantined.is_empty()); - } - - #[tokio::test] - async fn fetch_surfaces_non_200_as_quarantine_failed() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .respond_with(ResponseTemplate::new(503).set_body_string("backend down")) - .mount(&server) - .await; - let api_url = Url::parse(&server.uri()).unwrap(); - let err = fetch(&api_url, "tok", "owner/repo", "main") - .await - .expect_err("503 must surface as QuarantineFailed"); - assert!( - err.message.contains("503") || err.message.contains("backend down"), - "got: {}", - err.message, - ); - } -} diff --git a/crates/mergify-ci/src/junit_process/spans.rs b/crates/mergify-ci/src/junit_process/spans.rs deleted file mode 100644 index 4d863bea..00000000 --- a/crates/mergify-ci/src/junit_process/spans.rs +++ /dev/null @@ -1,853 +0,0 @@ -//! `TestCase` → OTLP `ExportTraceServiceRequest`. -//! -//! Span layout: -//! -//! - one root **session** span per upload (parent: optional -//! `MERGIFY_TRACEPARENT`), -//! - one **suite** span per `` (parent: session), -//! - one **case** span per `` (parent: suite). -//! -//! All spans share a single OTLP `Resource` carrying the CI -//! environment attributes the backend uses for routing and -//! dashboards (provider, pipeline, run, branch, …). Common -//! attributes (`test.framework`, `test.language`) — the -//! caller-supplied per-upload metadata — get folded into every -//! span on top of its scope-specific attributes. - -use std::collections::BTreeSet; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; -use opentelemetry_proto::tonic::common::v1::any_value::Value as AnyValueOneof; -use opentelemetry_proto::tonic::common::v1::{AnyValue, InstrumentationScope, KeyValue}; -use opentelemetry_proto::tonic::resource::v1::Resource; -use opentelemetry_proto::tonic::trace::v1::status::StatusCode; -use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span, Status}; - -use crate::detector; -use crate::junit_process::junit::{ParseResult, TestCase, TestStatus}; - -/// Caller-supplied per-upload metadata. `test_framework` and -/// `test_language` get propagated to every span as attributes. -#[derive(Debug, Clone, Default)] -pub struct UploadMetadata { - pub test_framework: Option, - pub test_language: Option, - /// Optional `mergify.test.job.name` attribute set when the - /// `MERGIFY_TEST_JOB_NAME` env var is present at parse time. - pub mergify_test_job_name: Option, - /// Set of test names the quarantine API confirmed are - /// currently quarantined. Each case span whose name is in - /// this set gets `cicd.test.quarantined = true`; everything - /// else defaults to `false`. Pass an empty set when the - /// quarantine check was skipped (no failures) or failed - /// (network/API error) — the spans then upload with every - /// case marked non-quarantined, which is the conservative - /// default. - pub quarantined: BTreeSet, -} - -/// The widest test name this client will upload, in bytes. -/// -/// Not a storage limit. The engine keys every table an oversized name can -/// reach on `uuid_generate_v5` of the name rather than the name itself, so -/// the name is stored whole and no btree tuple limit is tripped (MRGFY-8902). -/// What this bounds is what a wire payload and a UI can usefully carry: a -/// name at this size is 65 kB of generated text — INC-2436 was a Vitest title -/// interpolating stringified React source — that no one reads and that costs -/// every downstream reader who has to render it. -/// -/// Skipped rather than truncated, and reported rather than dropped quietly. -/// Truncating would mint a second identity for the same test, which is the -/// one outcome worse than not having the result: the test would split into -/// two histories and its flakiness and quarantine state with it. -pub(crate) const MAX_TEST_NAME_BYTES: usize = 65_536; - -/// Result of converting a [`ParseResult`] (one or more `JUnit` -/// files) into a wire-ready OTLP request. -#[derive(Debug, Clone)] -pub struct BuiltTraces { - /// Lowercase hex identifier the CLI prints back to the user. - /// Same value populates the `test.run.id` resource attribute. - pub run_id: String, - pub request: ExportTraceServiceRequest, - /// Names of cases left out because they exceed - /// [`MAX_TEST_NAME_BYTES`]. Reported to the user by the caller; - /// empty for every ordinary report. - pub oversized_case_names: Vec, -} - -/// Convert a [`ParseResult`] (the union of every parsed `JUnit` -/// file) into an OTLP `ExportTraceServiceRequest`. -/// -/// Random trace and span IDs are produced via [`getrandom::fill`]. -/// `now_unix_nanos` and `id_source` exist so tests can pin a -/// deterministic clock and randomness source; production callers -/// use [`build_traces`] which fills them from -/// `SystemTime::now()` and `getrandom`. -pub fn build_traces(parsed: &ParseResult, metadata: &UploadMetadata) -> BuiltTraces { - build_traces_with(parsed, metadata, system_now_unix_nanos(), &mut OsRandom) -} - -#[allow(clippy::too_many_lines)] // Straight-line span construction; splitting -// would just hide the per-span attribute set behind helper noise that's -// harder to skim than the inline form. -fn build_traces_with( - parsed: &ParseResult, - metadata: &UploadMetadata, - now_unix_nanos: u64, - rng: &mut dyn RandomBytes, -) -> BuiltTraces { - let trace_id = rng.bytes16(); - let session_span_id = rng.bytes8(); - let run_id = hex_lower(&session_span_id); - - let resource = build_resource(&run_id, metadata); - - let common_attrs = common_attributes(metadata); - - let mut spans: Vec = Vec::new(); - let mut oversized_case_names: Vec = Vec::new(); - - // Suite spans are appended after we know each suite's earliest - // case start (so the suite's start_time covers all its cases). - // Group the parser's flat case list by `suite_name`, preserving - // first-seen order so the wire output matches Python's - // document-order iteration over `` elements. - let suites = group_by_suite(&parsed.cases); - - let mut session_start_time_unix_nanos = now_unix_nanos; - - for (suite_name, suite_cases) in &suites { - let suite_span_id = rng.bytes8(); - let mut suite_start_time_unix_nanos = now_unix_nanos; - - let mut suite_case_count = 0_usize; - - for case in suite_cases { - // `len()` on a Rust `String` is already bytes, which is the unit - // the limit is stated in. - if case.name.len() > MAX_TEST_NAME_BYTES { - oversized_case_names.push(case.name.clone()); - continue; - } - suite_case_count += 1; - - let case_span_id = rng.bytes8(); - let start_time_unix_nanos = case_start_time(now_unix_nanos, case.duration); - suite_start_time_unix_nanos = suite_start_time_unix_nanos.min(start_time_unix_nanos); - - let mut attrs = common_attrs.clone(); - attrs.push(kv_string("test.scope", "case")); - attrs.push(kv_string("test.case.name", &case.name)); - attrs.push(kv_string("code.function.name", &case.name)); - attrs.push(kv_bool( - "cicd.test.quarantined", - metadata.quarantined.contains(&case.name), - )); - if let Some(file) = &case.file { - attrs.push(kv_string("code.filepath", file)); - } - if let Some(line) = &case.line { - attrs.push(kv_string("code.lineno", line)); - } - attrs.push(kv_string( - "test.case.result.status", - case.status.status_attr(), - )); - - let status_code = match case.status { - TestStatus::Passed | TestStatus::Skipped => StatusCode::Ok, - TestStatus::Failed | TestStatus::Errored => StatusCode::Error, - }; - if case.status.is_failure() { - if let Some(kind) = &case.failure.kind { - attrs.push(kv_string("exception.type", kind)); - } - if let Some(msg) = &case.failure.message { - attrs.push(kv_string("exception.message", msg)); - } - if let Some(trace) = &case.failure.stacktrace { - attrs.push(kv_string("exception.stacktrace", trace)); - } - } - - spans.push(Span { - trace_id: trace_id.to_vec(), - span_id: case_span_id.to_vec(), - trace_state: String::new(), - parent_span_id: suite_span_id.to_vec(), - flags: 0, - name: case.name.clone(), - kind: 0, - start_time_unix_nano: start_time_unix_nanos, - end_time_unix_nano: now_unix_nanos, - attributes: attrs, - dropped_attributes_count: 0, - events: Vec::new(), - dropped_events_count: 0, - links: Vec::new(), - dropped_links_count: 0, - status: Some(Status { - message: String::new(), - code: status_code.into(), - }), - }); - } - - // A suite that kept nothing emits nothing: a suite span with no - // children is a test suite the backend would show as having run zero - // cases, which is a different lie from the one being fixed. - if suite_case_count == 0 { - continue; - } - - session_start_time_unix_nanos = - session_start_time_unix_nanos.min(suite_start_time_unix_nanos); - - let mut suite_attrs = common_attrs.clone(); - suite_attrs.push(kv_string("test.case.name", suite_name)); - suite_attrs.push(kv_string("test.scope", "suite")); - spans.push(Span { - trace_id: trace_id.to_vec(), - span_id: suite_span_id.to_vec(), - trace_state: String::new(), - parent_span_id: session_span_id.to_vec(), - flags: 0, - name: suite_name.clone(), - kind: 0, - start_time_unix_nano: suite_start_time_unix_nanos, - end_time_unix_nano: now_unix_nanos, - attributes: suite_attrs, - dropped_attributes_count: 0, - events: Vec::new(), - dropped_events_count: 0, - links: Vec::new(), - dropped_links_count: 0, - status: None, - }); - } - - // Session is the root span. Place it FIRST in the wire vector - // so the backend has it before any child references it. - let mut session_attrs = common_attrs.clone(); - session_attrs.push(kv_string("test.scope", "session")); - let session_span = Span { - trace_id: trace_id.to_vec(), - span_id: session_span_id.to_vec(), - trace_state: String::new(), - parent_span_id: Vec::new(), - flags: 0, - name: "test session".to_string(), - kind: 0, - start_time_unix_nano: session_start_time_unix_nanos, - end_time_unix_nano: now_unix_nanos, - attributes: session_attrs, - dropped_attributes_count: 0, - events: Vec::new(), - dropped_events_count: 0, - links: Vec::new(), - dropped_links_count: 0, - status: None, - }; - spans.insert(0, session_span); - - let resource_spans = ResourceSpans { - resource: Some(resource), - scope_spans: vec![ScopeSpans { - scope: Some(InstrumentationScope { - name: "mergify-cli".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - attributes: Vec::new(), - dropped_attributes_count: 0, - }), - spans, - schema_url: String::new(), - }], - schema_url: String::new(), - }; - - BuiltTraces { - run_id, - request: ExportTraceServiceRequest { - resource_spans: vec![resource_spans], - }, - oversized_case_names, - } -} - -fn group_by_suite(cases: &[TestCase]) -> Vec<(String, Vec<&TestCase>)> { - // Preserve first-seen order. A `Vec<(K, Vec<&T>)>` linear-scan - // group-by is fine here — JUnit reports rarely exceed a handful - // of suites, and we get deterministic iteration for free. - let mut groups: Vec<(String, Vec<&TestCase>)> = Vec::new(); - for case in cases { - if let Some(existing) = groups.iter_mut().find(|(name, _)| *name == case.suite_name) { - existing.1.push(case); - } else { - groups.push((case.suite_name.clone(), vec![case])); - } - } - groups -} - -fn case_start_time(now_unix_nanos: u64, duration: Option) -> u64 { - // Mirror Python's `now - int(float(time) * 10e9)`. The `10e9` - // is a long-standing bug in the Python emitter (should be - // `1e9`), so cases appear ~10× longer on the wire than the - // JUnit report claims. The Mergify backend interprets the - // current shape, so we replicate it verbatim — fixing it - // here would silently change every uploaded dashboard. If - // the Python side ever fixes the multiplier, mirror the - // change in both places. - let Some(d) = duration else { - return now_unix_nanos; - }; - #[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss - )] - let scaled_nanos = (d.as_secs_f64() * 10e9) as u64; - now_unix_nanos.saturating_sub(scaled_nanos) -} - -fn build_resource(run_id: &str, metadata: &UploadMetadata) -> Resource { - let mut attrs = Vec::new(); - attrs.push(kv_string("test.run.id", run_id)); - - if let Some(job_name) = &metadata.mergify_test_job_name { - attrs.push(kv_string("mergify.test.job.name", job_name)); - } - - if let Some(name) = detector::get_pipeline_name() { - attrs.push(kv_string("cicd.pipeline.name", &name)); - } - if let Some(name) = detector::get_job_name() { - attrs.push(kv_string("cicd.pipeline.task.name", &name)); - } - if let Some(id) = detector::get_cicd_pipeline_run_id() { - attrs.push(kv_string("cicd.pipeline.run.id", &id)); - } - if let Some(url) = detector::get_cicd_pipeline_run_url() { - attrs.push(kv_string("cicd.pipeline.run.url", &url)); - } - if let Some(attempt) = detector::get_cicd_pipeline_run_attempt() { - #[allow(clippy::cast_possible_wrap)] - attrs.push(kv_int("cicd.pipeline.run.attempt", attempt as i64)); - } - if let Some(sha) = detector::get_head_sha() { - attrs.push(kv_string("vcs.ref.head.revision", &sha)); - } - if let Some(name) = detector::get_head_ref_name() { - attrs.push(kv_string("vcs.ref.head.name", &name)); - } - if let Some(name) = detector::get_base_ref_name() { - attrs.push(kv_string("vcs.ref.base.name", &name)); - } - if let Some(url) = detector::get_repository_url() { - attrs.push(kv_string("vcs.repository.url.full", &url)); - } - if let Some(repo) = detector::get_github_repository() { - attrs.push(kv_string("vcs.repository.name", &repo)); - } - if let Some(name) = detector::get_cicd_pipeline_runner_name() { - attrs.push(kv_string("cicd.pipeline.runner.name", &name)); - } - if let Some(provider) = detector::get_ci_provider() { - attrs.push(kv_string("cicd.provider.name", provider.as_str())); - } - - Resource { - attributes: attrs, - dropped_attributes_count: 0, - entity_refs: Vec::new(), - } -} - -fn common_attributes(metadata: &UploadMetadata) -> Vec { - let mut attrs = Vec::new(); - if let Some(framework) = &metadata.test_framework { - attrs.push(kv_string("test.framework", framework)); - } - if let Some(language) = &metadata.test_language { - attrs.push(kv_string("test.language", language)); - } - attrs -} - -fn kv(key: &str, value: AnyValueOneof) -> KeyValue { - // `..Default::default()` so any future proto-generated fields - // (e.g. the profiling-signal `key_strindex` already present in - // 0.32) round-trip as their default without us having to spell - // them out. - KeyValue { - key: key.to_string(), - value: Some(AnyValue { value: Some(value) }), - ..KeyValue::default() - } -} - -fn kv_string(key: &str, value: &str) -> KeyValue { - kv(key, AnyValueOneof::StringValue(value.to_string())) -} - -fn kv_bool(key: &str, value: bool) -> KeyValue { - kv(key, AnyValueOneof::BoolValue(value)) -} - -/// Shared with [`crate::junit_process::split`], which stamps the -/// per-chunk completeness markers onto an already-built resource. -pub(super) fn kv_int(key: &str, value: i64) -> KeyValue { - kv(key, AnyValueOneof::IntValue(value)) -} - -fn hex_lower(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut out = String::with_capacity(bytes.len() * 2); - for b in bytes { - // `write!` on a `String` is infallible; the `_` discards - // the `Ok` value rather than going through a `format!` - // round trip that allocates per byte. - let _ = write!(out, "{b:02x}"); - } - out -} - -fn system_now_unix_nanos() -> u64 { - match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(d) => u64::try_from(d.as_nanos()).unwrap_or(u64::MAX), - Err(_) => 0, - } -} - -/// Internal seam for tests: any source of random bytes. The -/// production impl reads from the OS via `getrandom`; tests -/// hand-feed deterministic byte streams. -trait RandomBytes { - fn bytes8(&mut self) -> [u8; 8]; - fn bytes16(&mut self) -> [u8; 16]; -} - -struct OsRandom; - -impl RandomBytes for OsRandom { - fn bytes8(&mut self) -> [u8; 8] { - let mut buf = [0u8; 8]; - getrandom::fill(&mut buf).expect("OS rng available"); - buf - } - fn bytes16(&mut self) -> [u8; 16] { - let mut buf = [0u8; 16]; - getrandom::fill(&mut buf).expect("OS rng available"); - buf - } -} - -#[cfg(test)] -mod tests { - use mergify_core::env; - - use super::*; - use crate::junit_process::junit::Failure; - - /// Deterministic byte source for tests. Bytes are consumed - /// in order. Tests provide enough buffer for the spans they - /// expect to build; running out of bytes panics so a wrong - /// span-count assumption is loud rather than silent. - struct FixedRng { - bytes: Vec, - cursor: usize, - } - impl FixedRng { - fn new(bytes: Vec) -> Self { - Self { bytes, cursor: 0 } - } - fn take(&mut self, n: usize) -> Vec { - let slice = self.bytes[self.cursor..self.cursor + n].to_vec(); - self.cursor += n; - slice - } - } - impl RandomBytes for FixedRng { - fn bytes8(&mut self) -> [u8; 8] { - let mut out = [0u8; 8]; - out.copy_from_slice(&self.take(8)); - out - } - fn bytes16(&mut self) -> [u8; 16] { - let mut out = [0u8; 16]; - out.copy_from_slice(&self.take(16)); - out - } - } - - fn sample_parsed() -> ParseResult { - ParseResult { - suite_names: vec!["pytest".to_string()], - cases: vec![ - TestCase { - name: "tests.test_func.test_success".to_string(), - suite_name: "pytest".to_string(), - duration: Some(Duration::from_secs_f64(0.001)), - file: None, - line: None, - status: TestStatus::Passed, - failure: Failure::default(), - }, - TestCase { - name: "tests.test_func.test_failed".to_string(), - suite_name: "pytest".to_string(), - duration: Some(Duration::from_secs_f64(0.002)), - file: Some("tests/test_func.py".to_string()), - line: Some("6".to_string()), - status: TestStatus::Failed, - failure: Failure { - kind: None, - message: Some("assert 1 == 0".to_string()), - stacktrace: Some("trace".to_string()), - }, - }, - ], - } - } - - /// A name past the cap costs its own result and nothing else. - #[test] - fn an_oversized_name_is_skipped_and_its_suite_mates_still_upload() { - let mut parsed = sample_parsed(); - // One byte over, so the assertion is about the boundary and not about - // some comfortably-huge number that a wrong `>=` would also pass. - let oversized = "x".repeat(MAX_TEST_NAME_BYTES + 1); - parsed.cases.push(TestCase { - name: oversized.clone(), - suite_name: "pytest".to_string(), - duration: Some(Duration::from_secs_f64(0.003)), - file: None, - line: None, - status: TestStatus::Passed, - failure: Failure::default(), - }); - - let mut bytes: Vec = Vec::with_capacity(16 + 4 * 8); - bytes.extend(std::iter::repeat_n(0xAA, 16)); - bytes.extend(std::iter::repeat_n(0x11, 8)); - bytes.extend(std::iter::repeat_n(0x22, 8)); - bytes.extend(std::iter::repeat_n(0x33, 8)); - bytes.extend(std::iter::repeat_n(0x44, 8)); - let mut rng = FixedRng::new(bytes); - - let now: u64 = 1_700_000_000_000_000_000; - let metadata = UploadMetadata::default(); - let built = - env::testing::with_no_vars(|| build_traces_with(&parsed, &metadata, now, &mut rng)); - - assert_eq!(built.oversized_case_names, vec![oversized.clone()]); - - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - // Reported, not uploaded -- in no span name and no attribute value. - assert!(!spans.iter().any(|s| s.name == oversized)); - assert!(!spans.iter().any(|s| s.attributes.iter().any(|a| { - matches!( - &a.value.as_ref().and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::StringValue(v)) if v == &oversized - ) - }))); - // The two well-named cases in the same suite are untouched. - assert!( - spans - .iter() - .any(|s| s.name == "tests.test_func.test_success") - ); - assert!( - spans - .iter() - .any(|s| s.name == "tests.test_func.test_failed") - ); - } - - /// A name exactly at the cap is uploaded: the limit is inclusive, and a - /// test sitting on it must not silently lose its history. - #[test] - fn a_name_exactly_at_the_cap_is_still_uploaded() { - let mut parsed = sample_parsed(); - let at_cap = "y".repeat(MAX_TEST_NAME_BYTES); - parsed.cases.push(TestCase { - name: at_cap.clone(), - suite_name: "pytest".to_string(), - duration: Some(Duration::from_secs_f64(0.003)), - file: None, - line: None, - status: TestStatus::Passed, - failure: Failure::default(), - }); - - let mut bytes: Vec = Vec::with_capacity(16 + 5 * 8); - bytes.extend(std::iter::repeat_n(0xAA, 16)); - bytes.extend(std::iter::repeat_n(0x11, 8)); - bytes.extend(std::iter::repeat_n(0x22, 8)); - bytes.extend(std::iter::repeat_n(0x33, 8)); - bytes.extend(std::iter::repeat_n(0x44, 8)); - bytes.extend(std::iter::repeat_n(0x55, 8)); - let mut rng = FixedRng::new(bytes); - - let built = env::testing::with_no_vars(|| { - build_traces_with( - &parsed, - &UploadMetadata::default(), - 1_700_000_000_000_000_000, - &mut rng, - ) - }); - - assert!(built.oversized_case_names.is_empty()); - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - assert!(spans.iter().any(|s| s.name == at_cap)); - } - - #[test] - fn builds_session_suite_and_case_spans_with_consistent_parent_chain() { - // 16 bytes for trace_id; 4×8 bytes for session, suite, - // case-1, case-2 span ids. Distinct fill bytes per region - // so the assertions below can tell them apart at a glance. - let mut bytes: Vec = Vec::with_capacity(16 + 4 * 8); - bytes.extend(std::iter::repeat_n(0xAA, 16)); // trace_id - bytes.extend(std::iter::repeat_n(0x11, 8)); // session - bytes.extend(std::iter::repeat_n(0x22, 8)); // suite - bytes.extend(std::iter::repeat_n(0x33, 8)); // case-1 - bytes.extend(std::iter::repeat_n(0x44, 8)); // case-2 - let mut rng = FixedRng::new(bytes); - - let now: u64 = 1_700_000_000_000_000_000; - let metadata = UploadMetadata::default(); - let built = env::testing::with_no_vars(|| { - build_traces_with(&sample_parsed(), &metadata, now, &mut rng) - }); - - // run_id is the session span id rendered as hex. - assert_eq!(built.run_id, "1111111111111111"); - - let resource_spans = &built.request.resource_spans; - assert_eq!(resource_spans.len(), 1); - let scope_spans = &resource_spans[0].scope_spans; - assert_eq!(scope_spans.len(), 1); - let spans = &scope_spans[0].spans; - // 1 session + 1 suite + 2 cases. - assert_eq!(spans.len(), 4); - - // Session is first; suite reports session as parent; both - // cases report suite as parent. - let session = &spans[0]; - assert_eq!(session.name, "test session"); - assert!(session.parent_span_id.is_empty()); - assert_eq!(session.span_id, vec![0x11; 8]); - assert_eq!(session.trace_id, vec![0xAA; 16]); - - let suite = spans.iter().find(|s| s.name == "pytest").unwrap(); - assert_eq!(suite.parent_span_id, vec![0x11; 8]); - assert_eq!(suite.span_id, vec![0x22; 8]); - - let cases: Vec<&Span> = spans - .iter() - .filter(|s| s.name.starts_with("tests.test_func")) - .collect(); - assert_eq!(cases.len(), 2); - for case in &cases { - assert_eq!(case.parent_span_id, vec![0x22; 8]); - assert_eq!(case.trace_id, vec![0xAA; 16]); - } - } - - #[test] - fn case_status_maps_to_otlp_status_code() { - let mut rng = FixedRng::new(vec![0xFF; 256]); - let now: u64 = 1_700_000_000_000_000_000; - let metadata = UploadMetadata::default(); - let built = env::testing::with_no_vars(|| { - build_traces_with(&sample_parsed(), &metadata, now, &mut rng) - }); - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - - let pass = spans - .iter() - .find(|s| s.name == "tests.test_func.test_success") - .unwrap(); - assert_eq!( - pass.status.as_ref().unwrap().code, - i32::from(StatusCode::Ok), - ); - - let fail = spans - .iter() - .find(|s| s.name == "tests.test_func.test_failed") - .unwrap(); - assert_eq!( - fail.status.as_ref().unwrap().code, - i32::from(StatusCode::Error), - ); - // exception.message / .stacktrace are attached to failing - // cases; passing ones don't get the keys at all. - let fail_attr_keys: Vec<&str> = fail.attributes.iter().map(|kv| kv.key.as_str()).collect(); - assert!(fail_attr_keys.contains(&"exception.message")); - assert!(fail_attr_keys.contains(&"exception.stacktrace")); - let pass_attr_keys: Vec<&str> = pass.attributes.iter().map(|kv| kv.key.as_str()).collect(); - assert!(!pass_attr_keys.contains(&"exception.message")); - } - - #[test] - fn case_attributes_include_file_line_and_code_function() { - let mut rng = FixedRng::new(vec![0xFF; 256]); - let metadata = UploadMetadata::default(); - let built = env::testing::with_no_vars(|| { - build_traces_with(&sample_parsed(), &metadata, 0, &mut rng) - }); - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - let fail = spans - .iter() - .find(|s| s.name == "tests.test_func.test_failed") - .unwrap(); - let by_key: std::collections::HashMap<&str, &AnyValue> = fail - .attributes - .iter() - .filter_map(|kv| kv.value.as_ref().map(|v| (kv.key.as_str(), v))) - .collect(); - // file/line straight passthrough from the parser. - assert!(matches!( - by_key.get("code.filepath").and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::StringValue(s)) if s == "tests/test_func.py" - )); - assert!(matches!( - by_key.get("code.lineno").and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::StringValue(s)) if s == "6" - )); - // code.function.name mirrors the test case name. - assert!(matches!( - by_key.get("code.function.name").and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::StringValue(s)) if s == "tests.test_func.test_failed" - )); - // cicd.test.quarantined defaults to false on every case; - // the quarantine layer flips it later (Phase C). - assert!(matches!( - by_key - .get("cicd.test.quarantined") - .and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::BoolValue(false)) - )); - } - - #[test] - fn resource_attributes_carry_ci_env_when_set() { - let mut rng = FixedRng::new(vec![0xFF; 256]); - let metadata = UploadMetadata::default(); - let built = env::testing::with_vars( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ("GITHUB_WORKFLOW", Some("CI")), - ("GITHUB_JOB", Some("build")), - ("GITHUB_RUN_ID", Some("12345")), - ("GITHUB_RUN_ATTEMPT", Some("2")), - ("GITHUB_SHA", Some("abc123")), - ("GITHUB_REF_NAME", Some("main")), - ("RUNNER_NAME", Some("runner-1")), - ], - || build_traces_with(&sample_parsed(), &metadata, 0, &mut rng), - ); - let resource = built.request.resource_spans[0].resource.as_ref().unwrap(); - let by_key: std::collections::HashMap<&str, &AnyValue> = resource - .attributes - .iter() - .filter_map(|kv| kv.value.as_ref().map(|v| (kv.key.as_str(), v))) - .collect(); - - for (key, expected) in [ - ("cicd.provider.name", "github_actions"), - ("cicd.pipeline.name", "CI"), - ("cicd.pipeline.task.name", "build"), - ("cicd.pipeline.run.id", "12345"), - ("vcs.ref.head.revision", "abc123"), - ("vcs.ref.head.name", "main"), - ("vcs.repository.name", "owner/repo"), - ("cicd.pipeline.runner.name", "runner-1"), - ] { - assert!( - matches!( - by_key.get(key).and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::StringValue(s)) if s == expected - ), - "expected resource attr {key} == {expected:?}, got {:?}", - by_key.get(key), - ); - } - // Attempt comes through as int, not string. - assert!(matches!( - by_key - .get("cicd.pipeline.run.attempt") - .and_then(|v| v.value.as_ref()), - Some(AnyValueOneof::IntValue(2)) - )); - } - - #[test] - fn common_attributes_propagate_to_every_span() { - let mut rng = FixedRng::new(vec![0xFF; 256]); - let metadata = UploadMetadata { - test_framework: Some("pytest".to_string()), - test_language: Some("python".to_string()), - mergify_test_job_name: None, - quarantined: BTreeSet::new(), - }; - let built = env::testing::with_no_vars(|| { - build_traces_with(&sample_parsed(), &metadata, 0, &mut rng) - }); - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - for span in spans { - let keys: Vec<&str> = span.attributes.iter().map(|kv| kv.key.as_str()).collect(); - assert!( - keys.contains(&"test.framework"), - "span {} missing test.framework: {keys:?}", - span.name - ); - assert!( - keys.contains(&"test.language"), - "span {} missing test.language: {keys:?}", - span.name - ); - } - } - - #[test] - fn timestamps_propagate_duration_and_session_envelopes_all_cases() { - // Cases of durations 0.001s and 0.002s. With the 10× scale - // Python uses, those become 0.01s and 0.02s in nanos before - // `now`. The session start must be the earliest case start. - let mut rng = FixedRng::new(vec![0xFF; 256]); - let now: u64 = 1_000_000_000_000_000_000; - let metadata = UploadMetadata::default(); - let built = env::testing::with_no_vars(|| { - build_traces_with(&sample_parsed(), &metadata, now, &mut rng) - }); - let spans = &built.request.resource_spans[0].scope_spans[0].spans; - let session = spans.iter().find(|s| s.name == "test session").unwrap(); - let suite = spans.iter().find(|s| s.name == "pytest").unwrap(); - let earliest_case = spans - .iter() - .filter(|s| s.name.starts_with("tests.")) - .map(|s| s.start_time_unix_nano) - .min() - .unwrap(); - assert_eq!(session.start_time_unix_nano, earliest_case); - assert_eq!(suite.start_time_unix_nano, earliest_case); - // End time is `now` for every span. - assert_eq!(session.end_time_unix_nano, now); - assert_eq!(suite.end_time_unix_nano, now); - for span in spans { - assert!( - span.end_time_unix_nano == now, - "{}: {}", - span.name, - span.end_time_unix_nano - ); - } - } -} diff --git a/crates/mergify-ci/src/junit_process/split.rs b/crates/mergify-ci/src/junit_process/split.rs deleted file mode 100644 index bfc8170a..00000000 --- a/crates/mergify-ci/src/junit_process/split.rs +++ /dev/null @@ -1,1142 +0,0 @@ -//! Split an oversized OTLP trace request into several uploads sized -//! against [`upload::MAX_GZIPPED_UPLOAD_BYTES`] — "against" and not -//! "under", by a bounded margin two paths take deliberately; see -//! [`Chunk`]. -//! -//! [`spans::build_traces`] produces one `ExportTraceServiceRequest` -//! for a whole run: a session root span, one suite span per -//! ``, and one case span per ``, all sharing a -//! single trace id. A very large `JUnit` report (tens of thousands of -//! cases, or a handful with enormous stack traces) can gzip past the -//! ingest cap. Rather than drop the entire upload, we partition the -//! case spans into several requests. -//! -//! Each chunk is a **self-contained trace**: it carries the session -//! span, the suite spans for the cases it holds, and those cases. -//! Every chunk keeps the original trace id and the original span ids, -//! so the backend reassembles them into one trace exactly as if a -//! single upload had carried them all — repeating the session/suite -//! spans across chunks is an idempotent upsert keyed by -//! `(trace_id, span_id)`. Splitting on the *gzipped* size (the bytes -//! actually sent) is the point of the exercise: gzip ratios vary -//! wildly between a run dominated by short case names and one -//! dominated by megabyte stack traces, so a raw-size heuristic can't -//! predict the compressed body. -//! -//! Each returned [`Chunk`] carries the gzipped bytes to post, so the -//! upload step never compresses again. They are the bytes the chunk -//! was sized against unless it was stamped, in which case they are -//! the gzip of the stamped payload — always what goes on the wire. -//! -//! # Declaring completeness -//! -//! Self-containment is what makes a chunk unreadable as a *part*: -//! once ingested it carries the session span and a set of case spans -//! that read as a whole run — the cases it happens to hold, with -//! nothing saying how many there were — so a consumer asking "did -//! this session report a failing test?" gets an answer from the -//! first chunk alone, before the rest of the run has landed. Every -//! chunk of a multi-chunk delivery therefore declares its own -//! position ([`CHUNK_INDEX_ATTRIBUTE`]) and the length of the -//! sequence ([`CHUNK_COUNT_ATTRIBUTE`]) as resource attributes. -//! -//! **Both attributes absent means "a single upload, and it is the -//! whole session".** That is the compatibility hinge, and it is why -//! a lone chunk is left unmarked: a session counts as incomplete only -//! when it declares a total it has not reached, so every client -//! published before this existed keeps counting as complete. Any -//! other convention (a required marker, a `0`-based index colliding -//! with proto defaults) would strip "complete" from every deployed -//! client at once. -//! -//! Strictly, absence means "this client says nothing", and an older -//! one that fanned out says nothing either — MRGFY-9128 carries what -//! it would take to tell the two apart, and the blind spot is spelled -//! out on [`DROPPED_CASES_ATTRIBUTE`], where it bites the same way. -//! -//! `i of n` rather than a "last chunk" flag: neither is knowable -//! until packing has finished, so both need the same after-the-fact -//! stamping pass, and the pair additionally distinguishes a *stalled* -//! delivery (chunk 3 of 5, an hour old) from one that merely hasn't -//! finished arriving. -//! -//! On the resource rather than in an HTTP header, though the header -//! would be cheaper — it would leave both the resource and the -//! session span byte-identical across chunks, and delete the re-gzip -//! pass with them. A header does not survive ingest: the consumer has -//! to answer "is this session complete?" long after the upload, -//! against stored telemetry, so the declaration has to sit in a field -//! that gets persisted. The cost is that one session then presents a -//! different resource attribute set per chunk, which assumes the -//! consumer does not key or dedupe on resource identity. -//! -//! # Declaring loss -//! -//! Reaching the declared total says every upload arrived; it does not -//! say every result did. A case whose own payload fits in no upload -//! is dropped here, and one whose name exceeds -//! [`spans::MAX_TEST_NAME_BYTES`] is dropped before this module ever -//! sees it — so a session can announce itself complete and still be -//! missing tests, biased towards the failing ones, since a megabyte -//! stack trace is what makes a case oversized in the first place. -//! -//! [`DROPPED_CASES_ATTRIBUTE`] carries how many were left out, -//! session-wide, on every chunk — so whichever chunk a consumer holds -//! tells it the session is amputated. Absent means nothing was -//! dropped, the same hinge as above. -//! -//! Nothing else in the payload says it. A dropped case span is the -//! only record of its own failure: the session and suite spans carry -//! no counters, and the run's failure count never leaves this process -//! — it feeds the CI verdict and the printed report, not the wire. So -//! a session that lost a failing case is, without this attribute, -//! byte-for-byte a session where that test never ran. -//! -//! [`spans::build_traces`]: crate::junit_process::spans::build_traces -//! [`spans::MAX_TEST_NAME_BYTES`]: crate::junit_process::spans::MAX_TEST_NAME_BYTES - -use std::collections::HashMap; - -use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; -use opentelemetry_proto::tonic::resource::v1::Resource; -use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span}; -use prost::Message as _; - -use crate::junit_process::spans::kv_int; -use crate::junit_process::upload; - -/// Resource attribute carrying this upload's 1-based position in the -/// session's chunk sequence. 1-based so it reads as "3 of 5" to -/// whoever ends up looking at it — not to keep an absent attribute -/// distinguishable from the first chunk, which it is anyway: resource -/// attributes are a repeated field, so absence here is absence from -/// the list and never a zero. That collision is only a risk further -/// downstream, in a consumer that stores the index in a column with a -/// default. -pub const CHUNK_INDEX_ATTRIBUTE: &str = "mergify.test.session.chunk.index"; - -/// Resource attribute carrying how many uploads the session was -/// split into. A reader has the whole session once it has seen this -/// many distinct chunks of the run. -pub const CHUNK_COUNT_ATTRIBUTE: &str = "mergify.test.session.chunk.count"; - -/// Resource attribute carrying how many test cases this client left -/// out of the upload entirely, across the whole session. -/// -/// The key spells out what it counts because it sits next to -/// [`CHUNK_COUNT_ATTRIBUTE`], and `dropped.count` there reads just as -/// easily as "how many chunks were dropped" — from which a consumer -/// could derive `received + dropped == chunk.count`, a rule that is -/// self-consistent and closes a session while results are missing. -/// That is the misread this whole module exists to prevent, so the -/// unit goes in the key; OTLP spells its own equivalent -/// `dropped_attributes_count` in the same protobuf. -/// -/// Omitted rather than stamped as `0` when nothing was lost, on the -/// same compatibility hinge as the chunk markers — and with the same -/// blind spot, which a consumer has to know: absent means "nothing -/// lost" from a client that stamps this, and "unknown" from an older -/// one. Today that gap is the chunk markers' alone — splitting has -/// been released since `2026.7.21.1`, so deployed clients do fan out -/// and say nothing about it, while the other refusal (a name over -/// [`spans::MAX_TEST_NAME_BYTES`]) is on `main` and in no release -/// yet, so no client in the wild drops a case for it. Reading absence -/// as whole is the deliberate trade the hinge makes; separating the -/// two needs a producer version on the resource and a consumer that -/// gates on it, which is MRGFY-9128. -/// -/// Session-wide rather than per-chunk on purpose: a consumer decides -/// on the session, and a per-chunk tally would make the answer depend -/// on which chunk it happened to read. -pub const DROPPED_CASES_ATTRIBUTE: &str = "mergify.test.session.dropped_cases.count"; - -/// Room held back from `cap` while packing, so stamping the -/// completeness attributes afterwards cannot push a packed chunk over -/// the real cap. -/// -/// The markers are stamped *after* packing because their values do -/// not exist until the partition is final. At most three ride on one -/// chunk — index, count and a drop tally — for ~145 bytes of protobuf -/// before compression, so a kibibyte is an order -/// of magnitude more than deflate can turn that into — and against -/// the production cap it is 0.005% of a 20 MiB budget that already -/// sits 5 MiB below the server's hard limit. What holds the constant -/// honest is `stamping_overhead_fits_in_the_reserved_headroom`, which -/// measures the gzipped cost of stamping; asserting that chunks land -/// under the cap does not, because on any ordinary fixture they land -/// far below it. -const CHUNK_MARKER_RESERVE_BYTES: usize = 1024; - -/// One upload: the request plus the gzipped bytes to post. The bytes -/// are always the gzip of `request` — including after -/// [`stamp_completeness_markers`] rewrites both — so the caller posts -/// them as they are and never gzips twice. -/// -/// What they were *sized* against depends on the path. A chunk out of -/// a fan-out is sized against `cap` minus -/// [`CHUNK_MARKER_RESERVE_BYTES`], which is what leaves stamping the -/// room to stay under `cap`. Two paths deliberately do not: a lone -/// case admitted at the hard cap, and a single upload that fits and -/// then declares a loss. Both can land a few dozen bytes past `cap`, -/// which is a client-side target sitting 5 MiB below what ingest -/// actually refuses (see [`upload::MAX_GZIPPED_UPLOAD_BYTES`]). -#[derive(Debug)] -pub struct Chunk { - pub request: ExportTraceServiceRequest, - pub compressed: Vec, -} - -/// Outcome of [`split_request`]. -#[derive(Debug)] -pub struct SplitOutcome { - /// One upload per entry, in order, each sized to gzip under the - /// cap (see [`Chunk`] for what "under" means once the - /// completeness markers are stamped). Empty only in the - /// pathological case where every case is individually oversized - /// (see `oversized_cases`). - pub chunks: Vec, - /// Names of case spans that gzip past the cap on their own — a - /// single test whose captured output/stack trace is so large it - /// can't fit in any upload. These are dropped from the upload - /// (there is nowhere to put them) and reported to the user; the - /// CI verdict is unaffected because it's computed from the parsed - /// cases, not the upload. - pub oversized_cases: Vec, -} - -/// Partition `request` into uploads sized so their gzipped bodies fit -/// `cap` — see [`Chunk`] for the two paths that overshoot it by a -/// bounded margin. -/// -/// A `cap` above [`CHUNK_MARKER_RESERVE_BYTES`] is what makes the -/// partition useful: a fanned-out one is packed to `cap` minus that -/// reserve, so the completeness markers stamped afterwards still fit. -/// Nothing enforces it — a smaller `cap` saturates the packing budget -/// to zero and degrades to one upload per case, or, for a case that -/// overflows `cap` on its own, to dropping it. Degrading beats -/// refusing here, and the tests use tiny caps deliberately. The -/// production caller passes [`upload::MAX_GZIPPED_UPLOAD_BYTES`]. -/// -/// The common case is a small report: the whole request already fits, -/// so a single-element `chunks` is returned after one gzip and the -/// upload path posts that exact payload. Only when the full payload -/// exceeds `cap` do we decompose it and pack the case spans into -/// several requests. -/// -/// `dropped_before_split` is how many cases the span builder already -/// refused on name length. They join the ones dropped here for -/// [`DROPPED_CASES_ATTRIBUTE`], because the two are one fact for a -/// consumer — a result missing from the session — and the report the -/// user reads already merges them into a single list. -/// -/// Returns `Err` only if gzip itself fails (an in-memory -/// `flate2` write, so effectively never) — surfaced rather than -/// swallowed so a compression failure reads as a diagnosable upload -/// error instead of silently dropping every case as "too large". -pub fn split_request( - request: &ExportTraceServiceRequest, - cap: usize, - dropped_before_split: usize, -) -> Result { - let compressed = gzip_request(request)?; - if compressed.len() <= cap { - return single_upload(request, compressed, dropped_before_split); - } - - // Decompose the single-resource / single-scope layout that - // `build_traces` emits. Anything else is unexpected and not - // structurally splittable here, so fall back to a lone chunk — - // the upload may be refused, but that's strictly better than - // panicking, and this branch is unreachable for our own builder. - let Some(decomposed) = Decomposed::from_request(request) else { - return single_upload(request, compressed, dropped_before_split); - }; - - let mut chunks = Vec::new(); - let mut oversized_cases = Vec::new(); - decomposed.pack( - &decomposed.cases, - cap.saturating_sub(CHUNK_MARKER_RESERVE_BYTES), - cap, - &mut chunks, - &mut oversized_cases, - )?; - stamp_completeness_markers(&mut chunks, dropped_before_split + oversized_cases.len())?; - Ok(SplitOutcome { - chunks, - oversized_cases, - }) -} - -/// The whole run in one upload: the payload fit, or it could not be -/// taken apart. It carries no chunk markers — a single upload *is* -/// the session — but it still declares any cases dropped before it, -/// which is the one thing a lone chunk can be missing. -/// -/// When the payload fit, those bytes were sized against the full -/// `cap`, not the packing budget, so stamping a drop count can push -/// them a few dozen bytes past `cap` — the same trade the packer -/// already takes for a lone oversized-but-shippable case, and worth -/// far more than fanning out a report that fits, against a cap that -/// keeps 5 MiB clear of the ingest limit. -/// -/// The other entry is the undecomposable payload, which was already -/// over `cap` before anything was stamped; nothing bounds it here and -/// nothing did before. It is unreachable for requests this crate -/// builds — it exists so an unexpected layout ships rather than -/// panics. -fn single_upload( - request: &ExportTraceServiceRequest, - compressed: Vec, - dropped_cases: usize, -) -> Result { - let mut chunks = vec![Chunk { - request: request.clone(), - compressed, - }]; - stamp_completeness_markers(&mut chunks, dropped_cases)?; - Ok(SplitOutcome { - chunks, - oversized_cases: Vec::new(), - }) -} - -/// Stamp each chunk with its position in the sequence and the -/// session's dropped-case tally, and re-gzip so the stored bytes stay -/// the ones that will be posted. -/// -/// A lone chunk that lost nothing is left untouched — attributes and -/// bytes both — which is the compatibility hinge in the module docs. -/// Re-compressing is otherwise confined to a fan-out, where the -/// multi-MiB upload I/O dwarfs one more gzip per chunk, or to the -/// rare lone upload that did lose a case. -fn stamp_completeness_markers( - chunks: &mut [Chunk], - dropped_cases: usize, -) -> Result<(), std::io::Error> { - // Both conversions are unreachable — neither a partition nor a - // drop tally that large fits in memory — and both resolve the - // same way when in doubt: never claim more completeness than we - // have. Declining to mark leaves a total unstated, which a reader - // treats as one upload; saturating a drop tally over-declares a - // loss, which costs a consumer its reduction. Under-declaring - // either would hand back a "complete" we cannot stand behind. - let Ok(count) = i64::try_from(chunks.len()) else { - return Ok(()); - }; - let dropped = i64::try_from(dropped_cases).unwrap_or(i64::MAX); - if count <= 1 && dropped == 0 { - return Ok(()); - } - - // The index comes out of a range typed by `count`, so it is an - // `i64` by construction and this loop needs no second conversion - // — no second answer to the impossibility decided above. - for (index, chunk) in (1..=count).zip(chunks.iter_mut()) { - // Only a request with no `ResourceSpans` at all reaches this - // skip, and it can only arrive through `single_upload` — the - // packed path builds every chunk with `assemble`, which - // always emits exactly one. Such a request carries no spans - // either, so there is no session to declare anything about; - // inventing a resource the payload does not have would say - // less than nothing. - let Some(resource_spans) = chunk.request.resource_spans.first_mut() else { - continue; - }; - let resource = resource_spans - .resource - .get_or_insert_with(Resource::default); - // A lone chunk reaches here only to declare a loss: marking - // it `1 of 1` would say "multi-chunk" about an upload that is - // the whole sequence, which is what absence already says. - if count > 1 { - resource - .attributes - .push(kv_int(CHUNK_INDEX_ATTRIBUTE, index)); - resource - .attributes - .push(kv_int(CHUNK_COUNT_ATTRIBUTE, count)); - } - if dropped > 0 { - resource - .attributes - .push(kv_int(DROPPED_CASES_ATTRIBUTE, dropped)); - } - chunk.compressed = gzip_request(&chunk.request)?; - } - Ok(()) -} - -/// A case span together with the suite span it hangs off (if any) and -/// its protobuf-encoded size (computed once, reused by -/// [`split_index`]). -struct CaseEntry<'a> { - case: &'a Span, - suite: Option<&'a Span>, - encoded_len: usize, -} - -/// The pieces of a built trace we need to reassemble arbitrary -/// subsets of its cases into standalone requests. -struct Decomposed<'a> { - template: &'a ExportTraceServiceRequest, - session: &'a Span, - cases: Vec>, -} - -impl<'a> Decomposed<'a> { - fn from_request(request: &'a ExportTraceServiceRequest) -> Option { - let [resource_spans] = request.resource_spans.as_slice() else { - return None; - }; - let [scope_spans] = resource_spans.scope_spans.as_slice() else { - return None; - }; - let spans = &scope_spans.spans; - - // Session is the sole root (empty parent). Suites are its - // direct children; everything else is a case hanging off a - // suite. Build a span-id → suite lookup so each case resolves - // its suite in one pass. - let session = spans.iter().find(|s| s.parent_span_id.is_empty())?; - let mut suites: HashMap<&[u8], &Span> = HashMap::new(); - for span in spans { - if span.parent_span_id == session.span_id && span.span_id != session.span_id { - suites.insert(span.span_id.as_slice(), span); - } - } - - let cases = spans - .iter() - .filter(|s| s.span_id != session.span_id && !suites.contains_key(s.span_id.as_slice())) - .map(|case| CaseEntry { - case, - suite: suites.get(case.parent_span_id.as_slice()).copied(), - encoded_len: case.encoded_len(), - }) - .collect(); - - Some(Self { - template: request, - session, - cases, - }) - } - - /// Recursively split `cases` until every produced request gzips - /// under `cap`, appending the uploads to `chunks`. A single case - /// that can't fit even on its own is recorded in `oversized` and - /// dropped. - /// - /// Each node re-gzips its own slice rather than estimating from a - /// ratio: gzip is the only exact measure of the wire size, and - /// this path only runs for the rare report that already exceeds - /// the cap, where the multi-MiB upload I/O dwarfs the extra - /// compression. Correctness (never ship an over-cap chunk) is - /// worth the ~log(chunk-count) re-compressions. - /// Two caps, and the difference matters. `soft_cap` is the - /// packing target — `cap` minus the room stamping will need — and - /// it decides when to keep splitting. `hard_cap` is the real - /// limit, and it decides only whether a case is droppable: a lone - /// case that overflows the soft cap but fits the hard one is - /// shipped rather than discarded. Testing the drop against the - /// soft cap instead would move the "too large to upload" - /// threshold down by the reserve and silently stop delivering - /// results this CLI used to deliver — a test's results are worth - /// far more than the ~100 bytes of overshoot, which the hard - /// cap's own 5 MiB of headroom below the ingest limit absorbs. - fn pack( - &self, - cases: &[CaseEntry<'a>], - soft_cap: usize, - hard_cap: usize, - chunks: &mut Vec, - oversized: &mut Vec, - ) -> Result<(), std::io::Error> { - let request = self.assemble(cases); - let compressed = gzip_request(&request)?; - if compressed.len() <= soft_cap { - chunks.push(Chunk { - request, - compressed, - }); - return Ok(()); - } - match cases { - // Nothing to ship and nothing to report: only the - // top-level call can arrive here empty, since - // `split_index` never yields an empty half. - [] => return Ok(()), - // The lone case (plus the unavoidable session/suite - // framing) still overflows — nowhere left to split. - [entry] => { - if compressed.len() <= hard_cap { - chunks.push(Chunk { - request, - compressed, - }); - } else { - oversized.push(entry.case.name.clone()); - } - return Ok(()); - } - _ => {} - } - let mid = split_index(cases); - self.pack(&cases[..mid], soft_cap, hard_cap, chunks, oversized)?; - self.pack(&cases[mid..], soft_cap, hard_cap, chunks, oversized) - } - - /// Build a standalone request carrying `cases`: the session span, - /// the distinct suite spans those cases belong to (first-seen - /// order), then the case spans. Resource and scope metadata are - /// cloned from the original so the chunk routes identically. - fn assemble(&self, cases: &[CaseEntry<'a>]) -> ExportTraceServiceRequest { - let resource_spans = &self.template.resource_spans[0]; - let scope_spans = &resource_spans.scope_spans[0]; - - let mut spans = Vec::with_capacity(1 + cases.len()); - spans.push(self.session.clone()); - - let mut seen_suites: Vec<&[u8]> = Vec::new(); - for entry in cases { - if let Some(suite) = entry.suite - && !seen_suites.contains(&suite.span_id.as_slice()) - { - seen_suites.push(suite.span_id.as_slice()); - spans.push(suite.clone()); - } - } - for entry in cases { - spans.push(entry.case.clone()); - } - - ExportTraceServiceRequest { - resource_spans: vec![ResourceSpans { - resource: resource_spans.resource.clone(), - scope_spans: vec![ScopeSpans { - scope: scope_spans.scope.clone(), - spans, - schema_url: scope_spans.schema_url.clone(), - }], - schema_url: resource_spans.schema_url.clone(), - }], - } - } -} - -/// Pick the index that splits `cases` into two halves of roughly -/// equal *encoded* size, so one giant case is isolated fast instead -/// of dragged through many count-based halvings. The result is always -/// in `1..cases.len()` so both halves are non-empty (caller -/// guarantees `cases.len() >= 2`). -fn split_index(cases: &[CaseEntry]) -> usize { - let total: usize = cases.iter().map(|e| e.encoded_len).sum(); - let mut acc = 0; - for (i, entry) in cases.iter().enumerate() { - acc += entry.encoded_len; - if acc * 2 >= total { - return (i + 1).clamp(1, cases.len() - 1); - } - } - // Unreachable for non-empty encoded sizes; keep a sane fallback. - cases.len() / 2 -} - -/// gzip a request the same way [`upload::upload`] does. The error is -/// propagated so a compression failure surfaces as a real upload -/// error rather than being mistaken for an oversized payload. -fn gzip_request(request: &ExportTraceServiceRequest) -> Result, std::io::Error> { - upload::gzip(&request.encode_to_vec()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::junit_process::junit::{Failure, ParseResult, TestCase, TestStatus}; - use crate::junit_process::spans::{UploadMetadata, build_traces}; - use crate::testing::incompressible; - use mergify_core::env; - use std::collections::BTreeSet; - use std::time::Duration; - - fn case(name: &str, suite: &str, stacktrace_len: usize) -> TestCase { - // Derive a stable per-case seed from the name so repeated - // builds are deterministic. - let seed = name.bytes().fold(1u64, |acc, b| { - acc.wrapping_mul(31).wrapping_add(u64::from(b)) - }); - TestCase { - name: name.to_string(), - suite_name: suite.to_string(), - duration: Some(Duration::from_secs(0)), - file: None, - line: None, - status: if stacktrace_len > 0 { - TestStatus::Failed - } else { - TestStatus::Passed - }, - failure: Failure { - kind: None, - message: None, - stacktrace: (stacktrace_len > 0).then(|| incompressible(seed, stacktrace_len)), - }, - } - } - - fn build(cases: Vec) -> ExportTraceServiceRequest { - let parsed = ParseResult { - suite_names: cases.iter().map(|c| c.suite_name.clone()).collect(), - cases, - }; - let metadata = UploadMetadata { - test_framework: Some("pytest".to_string()), - test_language: Some("python".to_string()), - mergify_test_job_name: None, - quarantined: BTreeSet::new(), - }; - env::testing::with_no_vars(|| build_traces(&parsed, &metadata)).request - } - - /// Collect the `test.case.name` of every case span in a chunk. - fn case_names(chunk: &Chunk) -> Vec { - spans_with_scope(chunk, "case") - .map(|s| s.name.clone()) - .collect() - } - - /// Read an integer resource attribute off a chunk, or `None` - /// when the chunk doesn't carry it. - fn resource_int(chunk: &Chunk, key: &str) -> Option { - use opentelemetry_proto::tonic::common::v1::any_value::Value; - chunk.request.resource_spans[0] - .resource - .as_ref()? - .attributes - .iter() - .find(|kv| kv.key == key) - .and_then(|kv| match kv.value.as_ref()?.value.as_ref()? { - Value::IntValue(v) => Some(*v), - _ => None, - }) - } - - /// Iterate the spans of a chunk whose `test.scope` attribute - /// equals `scope` (`session` / `suite` / `case`). - fn spans_with_scope<'a>(chunk: &'a Chunk, scope: &'a str) -> impl Iterator { - use opentelemetry_proto::tonic::common::v1::any_value::Value; - chunk.request.resource_spans[0].scope_spans[0] - .spans - .iter() - .filter(move |s| { - s.attributes.iter().any(|kv| { - kv.key == "test.scope" - && matches!( - kv.value.as_ref().and_then(|v| v.value.as_ref()), - Some(Value::StringValue(v)) if v == scope - ) - }) - }) - } - - #[test] - fn small_report_stays_a_single_chunk() { - let request = build(vec![case("t.a", "suite", 0), case("t.b", "suite", 0)]); - let outcome = split_request(&request, upload::MAX_GZIPPED_UPLOAD_BYTES, 0).unwrap(); - assert_eq!(outcome.chunks.len(), 1); - assert!(outcome.oversized_cases.is_empty()); - // Byte-identical to the un-split request: the common path must - // not perturb the payload, and the compressed bytes are the - // gzip of exactly that request (posted as-is, no re-gzip). - assert_eq!( - outcome.chunks[0].request.encode_to_vec(), - request.encode_to_vec() - ); - assert_eq!( - outcome.chunks[0].compressed, - gzip_request(&request).unwrap() - ); - } - - #[test] - fn large_report_splits_into_under_cap_chunks_covering_every_case() { - // 40 cases × ~2 KiB of stacktrace each. With a 4 KiB cap the - // whole thing must fan out into several uploads. - let cases: Vec = (0..40) - .map(|i| case(&format!("t.case_{i}"), "suite", 2048)) - .collect(); - let request = build(cases); - let cap = 4 * 1024; - - let outcome = split_request(&request, cap, 0).unwrap(); - - assert!( - outcome.chunks.len() > 1, - "expected a fan-out, got {} chunk(s)", - outcome.chunks.len() - ); - assert!(outcome.oversized_cases.is_empty()); - - // Every chunk's stored bytes are under the cap and are the - // gzip of its own request; each chunk is self-contained (has - // the session span). - for chunk in &outcome.chunks { - assert!(chunk.compressed.len() <= cap, "chunk exceeds cap"); - assert_eq!(chunk.compressed, gzip_request(&chunk.request).unwrap()); - let spans = &chunk.request.resource_spans[0].scope_spans[0].spans; - assert!( - spans.iter().any(|s| s.parent_span_id.is_empty()), - "chunk missing session span" - ); - } - - // Union of case names across chunks == the original set, no - // loss and no duplication. - let mut got: Vec = outcome.chunks.iter().flat_map(case_names).collect(); - got.sort(); - let mut want: Vec = (0..40).map(|i| format!("t.case_{i}")).collect(); - want.sort(); - assert_eq!(got, want); - - // Every chunk carries the same trace id — one trace, many - // uploads. - let trace_id = &request.resource_spans[0].scope_spans[0].spans[0].trace_id; - for chunk in &outcome.chunks { - for span in &chunk.request.resource_spans[0].scope_spans[0].spans { - assert_eq!(&span.trace_id, trace_id, "trace id drifted across chunks"); - } - } - } - - #[test] - fn oversized_single_case_is_reported_and_dropped_but_others_upload() { - // One monster case that can't fit under the cap on its own, - // alongside two normal ones that must still upload. - let cap = 4 * 1024; - let request = build(vec![ - case("t.normal_a", "suite", 256), - case("t.monster", "suite", 64 * 1024), - case("t.normal_b", "suite", 256), - ]); - - let outcome = split_request(&request, cap, 0).unwrap(); - - assert_eq!(outcome.oversized_cases, vec!["t.monster".to_string()]); - for chunk in &outcome.chunks { - assert!(chunk.compressed.len() <= cap); - } - let uploaded: Vec = outcome.chunks.iter().flat_map(case_names).collect(); - assert!(uploaded.contains(&"t.normal_a".to_string())); - assert!(uploaded.contains(&"t.normal_b".to_string())); - assert!( - !uploaded.contains(&"t.monster".to_string()), - "oversized case must not be uploaded" - ); - } - - #[test] - fn single_chunk_carries_no_completeness_marker() { - let request = build(vec![case("t.a", "suite", 0), case("t.b", "suite", 0)]); - let outcome = split_request(&request, upload::MAX_GZIPPED_UPLOAD_BYTES, 0).unwrap(); - assert_eq!(outcome.chunks.len(), 1); - assert_eq!( - resource_int(&outcome.chunks[0], CHUNK_INDEX_ATTRIBUTE), - None - ); - assert_eq!( - resource_int(&outcome.chunks[0], CHUNK_COUNT_ATTRIBUTE), - None - ); - // Nothing was lost either, so the third marker is absent too: - // this payload is what every deployed client sends, and all - // three absences have to keep meaning "whole". - assert_eq!( - resource_int(&outcome.chunks[0], DROPPED_CASES_ATTRIBUTE), - None - ); - } - - #[test] - fn unsplittable_request_shape_stays_unmarked() { - // `Decomposed::from_request` bails on any layout our builder - // doesn't emit, and the lone fallback chunk is the whole - // payload. `cap: 0` forces the over-cap branch without needing - // a multi-MiB fixture. - let mut request = build(vec![case("t.a", "suite", 0)]); - // Two resource_spans is not the single-resource layout - // `build_traces` produces, so decomposition declines. - let extra = request.resource_spans[0].clone(); - request.resource_spans.push(extra); - - let outcome = split_request(&request, 0, 0).unwrap(); - - assert_eq!(outcome.chunks.len(), 1); - assert!(outcome.oversized_cases.is_empty()); - assert_eq!( - resource_int(&outcome.chunks[0], CHUNK_INDEX_ATTRIBUTE), - None - ); - assert_eq!( - resource_int(&outcome.chunks[0], CHUNK_COUNT_ATTRIBUTE), - None - ); - } - - #[test] - fn split_chunks_declare_their_position_and_stay_under_cap() { - let cases: Vec = (0..40) - .map(|i| case(&format!("t.case_{i}"), "suite", 2048)) - .collect(); - let request = build(cases); - let cap = 4 * 1024; - - let outcome = split_request(&request, cap, 0).unwrap(); - let total = i64::try_from(outcome.chunks.len()).unwrap(); - assert!(total > 1, "expected a fan-out, got {total} chunk(s)"); - - for (position, chunk) in outcome.chunks.iter().enumerate() { - let index = i64::try_from(position).unwrap() + 1; - assert_eq!( - resource_int(chunk, CHUNK_INDEX_ATTRIBUTE), - Some(index), - "chunk {index} misdeclares its position" - ); - assert_eq!( - resource_int(chunk, CHUNK_COUNT_ATTRIBUTE), - Some(total), - "chunk {index} misdeclares the sequence length" - ); - // The reserve exists so stamping can't push a packed - // chunk past the cap. Measure the stamped bytes rather - // than trusting the constant, and check the stored body - // is the one that will actually be POSTed. - assert_eq!(chunk.compressed, gzip_request(&chunk.request).unwrap()); - assert!( - chunk.compressed.len() <= cap, - "chunk {index} exceeds the cap once marked: {} > {cap}", - chunk.compressed.len() - ); - // Nothing was dropped here, so nothing claims a loss: - // absence is the whole convention, and stamping a `0` - // instead would say "amputated by nothing" on the most - // ordinary fan-out there is. - assert_eq!( - resource_int(chunk, DROPPED_CASES_ATTRIBUTE), - None, - "chunk {index} declared a loss that never happened" - ); - } - } - - #[test] - fn marker_keys_are_pinned_in_the_mergify_namespace() { - // These strings are the wire contract the engine reads; - // renaming the constant is free, renaming its value is a - // protocol break. `mergify.` is where this builder already - // files its vendor-only concepts (`mergify.test.job.name`), - // which is what a chunk total is. - assert_eq!(CHUNK_INDEX_ATTRIBUTE, "mergify.test.session.chunk.index"); - assert_eq!(CHUNK_COUNT_ATTRIBUTE, "mergify.test.session.chunk.count"); - assert_eq!( - DROPPED_CASES_ATTRIBUTE, - "mergify.test.session.dropped_cases.count" - ); - } - - #[test] - fn a_fan_out_declares_the_cases_it_could_not_carry() { - // Reaching the declared total says every upload arrived, not - // that every result did: the monster case fits in no upload - // and is dropped. Without a count, that session announces - // itself whole while missing a test — and the missing ones - // skew towards failures, which is what the reduction would - // then be answering over. - let cap = 4 * 1024; - let request = build(vec![ - case("t.normal_a", "suite", 2048), - case("t.monster", "suite", 64 * 1024), - case("t.normal_b", "suite", 2048), - case("t.normal_c", "suite", 2048), - ]); - - let outcome = split_request(&request, cap, 0).unwrap(); - - assert_eq!(outcome.oversized_cases, vec!["t.monster".to_string()]); - assert!( - outcome.chunks.len() > 1, - "fixture must fan out for this test to say anything" - ); - for chunk in &outcome.chunks { - // On every chunk, not just one: a consumer holding any - // single upload has to be able to tell. - assert_eq!( - resource_int(chunk, DROPPED_CASES_ATTRIBUTE), - Some(1), - "a chunk carried no loss count" - ); - assert_eq!(chunk.compressed, gzip_request(&chunk.request).unwrap()); - } - } - - #[test] - fn a_lone_upload_missing_a_case_declares_the_loss_and_no_sequence() { - // The span builder refuses an over-long test name before the - // split runs, so a report that fits in one upload can still - // be missing results. The loss is declared; the chunk markers - // are not, because one upload is the whole sequence. - let request = build(vec![case("t.a", "suite", 0)]); - - let outcome = split_request(&request, upload::MAX_GZIPPED_UPLOAD_BYTES, 2).unwrap(); - - assert_eq!(outcome.chunks.len(), 1); - let chunk = &outcome.chunks[0]; - assert_eq!(resource_int(chunk, DROPPED_CASES_ATTRIBUTE), Some(2)); - assert_eq!(resource_int(chunk, CHUNK_INDEX_ATTRIBUTE), None); - assert_eq!(resource_int(chunk, CHUNK_COUNT_ATTRIBUTE), None); - // Stamped after the sizing gzip, so the stored bytes must - // have been recomputed — they are the ones posted. - assert_eq!(chunk.compressed, gzip_request(&chunk.request).unwrap()); - } - - #[test] - fn losses_from_both_causes_are_added_up() { - // The two refusals — a name over the limit, a payload over - // the cap — are independent, and the report most likely to - // hit both at once is exactly the one that fans out: a huge - // run with megabyte stack traces. Taking either side alone - // (a max, a forgotten addend) under-counts the amputation on - // the one shape this attribute exists for. - let cap = 4 * 1024; - let request = build(vec![ - case("t.normal_a", "suite", 2048), - case("t.monster_a", "suite", 64 * 1024), - case("t.normal_b", "suite", 2048), - case("t.monster_b", "suite", 64 * 1024), - ]); - - // Both addends above one, so a tally that collapsed either - // side to "some were lost" would show here. The consumer acts - // on how amputated a session is, not on whether it is. - let outcome = split_request(&request, cap, 3).unwrap(); - - assert_eq!(outcome.oversized_cases.len(), 2); - for chunk in &outcome.chunks { - assert_eq!( - resource_int(chunk, DROPPED_CASES_ATTRIBUTE), - Some(5), - "3 refused on name length + 2 refused on size = 5" - ); - } - } - - #[test] - fn every_chunk_of_a_split_is_a_distinct_payload() { - // The backend registers arrivals by payload hash - // (`span_test_summary.processed_chunk_hashes`, MRGFY-9104), so - // two identical bodies would collapse into one and a session - // could never reach its declared total. Distinctness comes - // from `pack` giving each chunk a different set of cases — - // this pins that, not the markers; the distinct index each - // chunk carries is a second line of defence, not the one - // under test here. - let cases: Vec = (0..40) - .map(|i| case(&format!("t.case_{i}"), "suite", 2048)) - .collect(); - let outcome = split_request(&build(cases), 4 * 1024, 0).unwrap(); - assert!(outcome.chunks.len() > 1); - - let distinct: BTreeSet<&[u8]> = outcome - .chunks - .iter() - .map(|c| c.compressed.as_slice()) - .collect(); - assert_eq!( - distinct.len(), - outcome.chunks.len(), - "two chunks share a payload; the backend would count them once" - ); - } - - #[test] - fn the_session_span_stays_byte_identical_across_chunks() { - // The module's idempotent-upsert promise — every chunk - // re-sends the same session span, keyed `(trace_id, span_id)` - // — is why repeating it across uploads is safe. The - // completeness markers ride on the *resource* precisely so - // that promise survives: a per-chunk-varying session-span - // attribute would make whichever upload lands last decide the - // session's value. - let cases: Vec = (0..40) - .map(|i| case(&format!("t.case_{i}"), "suite", 2048)) - .collect(); - let outcome = split_request(&build(cases), 4 * 1024, 0).unwrap(); - assert!(outcome.chunks.len() > 1); - - let encoded: BTreeSet> = outcome - .chunks - .iter() - .map(|chunk| { - spans_with_scope(chunk, "session") - .next() - .expect("every chunk carries the session span") - .encode_to_vec() - }) - .collect(); - assert_eq!(encoded.len(), 1, "the session span drifted across chunks"); - } - - #[test] - fn a_lone_case_between_the_packing_budget_and_the_cap_still_ships() { - // The reserve narrows what `pack` will accept, and it must not - // narrow what counts as undeliverable with it: a case that - // cannot be split further but does fit the real cap has to - // ship. Testing the drop against the packing budget instead - // would move the "too large to upload" threshold down by the - // reserve and stop delivering results this CLI used to - // deliver — for the sake of ~40 bytes of stamping, against a - // cap that already keeps 5 MiB clear of the ingest limit. - let request = build(vec![ - case("t.case_a", "suite", 4096), - case("t.case_b", "suite", 4096), - ]); - // Size a lone case's own chunk exactly as `pack` would, then - // set the cap just above it — so each case clears the cap and - // fails the packing budget, which is the band at issue. - let decomposed = Decomposed::from_request(&request).expect("built layout decomposes"); - let lone = gzip_request(&decomposed.assemble(&decomposed.cases[..1])) - .unwrap() - .len(); - let cap = lone + 16; - assert!( - cap > CHUNK_MARKER_RESERVE_BYTES, - "fixture must leave a real packing budget" - ); - - let outcome = split_request(&request, cap, 0).unwrap(); - - assert!( - outcome.oversized_cases.is_empty(), - "a case that fits the cap was dropped: {:?}", - outcome.oversized_cases - ); - let mut names: Vec = outcome.chunks.iter().flat_map(case_names).collect(); - names.sort(); - assert_eq!(names, vec!["t.case_a".to_string(), "t.case_b".to_string()]); - - // Chunks taking this escape are stamped like any other, so - // they can end up a few dozen bytes past `cap`. That overshoot - // is the deliberate trade, and it is bounded by the reserve. - for chunk in &outcome.chunks { - assert!( - chunk.compressed.len() <= cap + CHUNK_MARKER_RESERVE_BYTES, - "overshoot beyond the reserved headroom: {} > {}", - chunk.compressed.len(), - cap + CHUNK_MARKER_RESERVE_BYTES - ); - } - } - - #[test] - fn a_lone_packed_chunk_is_left_unmarked() { - // Packing can land on a single chunk: `assemble` reorders the - // spans (session, then suites, then cases) and that layout can - // gzip smaller than the one `build_traces` emitted. The result - // is still the whole session, so it must read as unmarked like - // any other single upload. - let request = build(vec![case("t.a", "suite", 0)]); - let compressed = gzip_request(&request).unwrap(); - let mut chunks = vec![Chunk { - request, - compressed, - }]; - - stamp_completeness_markers(&mut chunks, 0).unwrap(); - - assert_eq!(resource_int(&chunks[0], CHUNK_INDEX_ATTRIBUTE), None); - assert_eq!(resource_int(&chunks[0], CHUNK_COUNT_ATTRIBUTE), None); - } - - #[test] - fn stamping_overhead_fits_in_the_reserved_headroom() { - // What `CHUNK_MARKER_RESERVE_BYTES` claims is that stamping a - // packed chunk cannot grow its gzipped body by more than the - // reserve — that claim is about deflate, so measure it rather - // than reason about it. Asserting the chunks land under the - // cap does not cover this: on any ordinary fixture they land - // far below it, so the reserve could be zero and nothing - // would notice until a report packed right up to the line. - let cases: Vec = (0..40) - .map(|i| case(&format!("t.case_{i}"), "suite", 2048)) - .collect(); - // Stamped with all three markers — a fan-out that also lost a - // case is the widest stamp there is, so it is the one the - // reserve has to cover. - let outcome = split_request(&build(cases), 4 * 1024, 7).unwrap(); - assert!(outcome.chunks.len() > 1); - - let mut largest_overhead = 0usize; - for chunk in &outcome.chunks { - let mut bare = chunk.request.clone(); - let attributes = &mut bare.resource_spans[0] - .resource - .as_mut() - .expect("built chunks carry a resource") - .attributes; - let before = attributes.len(); - attributes.retain(|kv| { - kv.key != CHUNK_INDEX_ATTRIBUTE - && kv.key != CHUNK_COUNT_ATTRIBUTE - && kv.key != DROPPED_CASES_ATTRIBUTE - }); - assert_eq!(before - attributes.len(), 3, "all markers must be present"); - - let overhead = chunk - .compressed - .len() - .saturating_sub(gzip_request(&bare).unwrap().len()); - largest_overhead = largest_overhead.max(overhead); - } - - assert!( - largest_overhead > 0, - "measured no overhead at all — the comparison is not exercising the markers" - ); - assert!( - largest_overhead <= CHUNK_MARKER_RESERVE_BYTES, - "stamping costs {largest_overhead} gzipped bytes, more than the \ - {CHUNK_MARKER_RESERVE_BYTES} held back while packing" - ); - } - - #[test] - fn cases_from_distinct_suites_keep_their_suite_span_in_each_chunk() { - // Two suites, enough stacktrace to force a split; whichever - // suite a case lands in, its suite span must ride along. - let cases: Vec = (0..20) - .map(|i| { - let suite = if i % 2 == 0 { "alpha" } else { "beta" }; - case(&format!("t.case_{i}"), suite, 2048) - }) - .collect(); - let request = build(cases); - let cap = 4 * 1024; - - let outcome = split_request(&request, cap, 0).unwrap(); - assert!(outcome.chunks.len() > 1); - - for chunk in &outcome.chunks { - // Every case's parent suite span is present in the chunk. - let suite_ids: BTreeSet<&[u8]> = spans_with_scope(chunk, "suite") - .map(|s| s.span_id.as_slice()) - .collect(); - for case_span in spans_with_scope(chunk, "case") { - assert!( - suite_ids.contains(case_span.parent_span_id.as_slice()), - "case {} has no suite span in its chunk", - case_span.name - ); - } - } - } -} diff --git a/crates/mergify-ci/src/junit_process/upload.rs b/crates/mergify-ci/src/junit_process/upload.rs deleted file mode 100644 index c378537c..00000000 --- a/crates/mergify-ci/src/junit_process/upload.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! POST an `ExportTraceServiceRequest` to the Mergify CI Insights -//! traces endpoint as OTLP/HTTP/protobuf with gzip. -//! -//! A single `POST` with three headers: -//! -//! - `Authorization: Bearer ` -//! - `Content-Type: application/x-protobuf` -//! - `Content-Encoding: gzip` -//! -//! No retries, no streaming, no SDK lifecycle — small enough to do -//! by hand with `reqwest` so we don't drag in `opentelemetry-otlp` -//! and its tonic dependency. Endpoint: -//! `{api_url}/v1/repos/{repository}/ci/traces`. - -use std::io::Write as _; -use std::time::Duration; - -use flate2::Compression; -use flate2::write::GzEncoder; -use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; -use prost::Message as _; - -#[derive(Debug)] -pub struct UploadError { - pub status: Option, - pub message: String, -} - -impl UploadError { - /// True when the server understood the request and refused it — - /// a permanent rejection (bad token, missing ingest permission, - /// unknown repository) that no retry will fix. Follows OTLP - /// retryability semantics: 4xx is final except 408 (timeout) - /// and 429 (throttling); 5xx and transport errors are - /// transient. - #[must_use] - pub fn is_rejection(&self) -> bool { - match self.status { - Some(status) => (400..500).contains(&status) && status != 408 && status != 429, - None => false, - } - } -} - -impl std::fmt::Display for UploadError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(status) = self.status { - write!( - f, - "Failed to export span batch code: {status}, reason: {msg}", - msg = self.message, - ) - } else { - write!(f, "Failed to export span batch: {}", self.message) - } - } -} - -impl std::error::Error for UploadError {} - -const ENDPOINT_PATH: &str = "/v1/repos/"; -const ENDPOINT_SUFFIX: &str = "/ci/traces"; - -/// Soft cap on the size of a single gzipped OTLP upload, in bytes -/// (20 MiB). This is the client-side split *target*, not the server's -/// limit: the ingest endpoint hard-caps payloads at 25 MiB -/// (MRGFY-8124), so sizing each chunk under 20 MiB leaves 5 MiB of -/// headroom below the rejection threshold. [`crate::junit_process::split`] -/// partitions an oversized trace into several uploads sized against -/// this cap — two of its paths land a few dozen bytes over it on -/// purpose, which is what the 5 MiB is for. The cap is on the -/// *compressed* body — the exact bytes we put on the wire — not the -/// raw XML or the uncompressed protobuf. -pub const MAX_GZIPPED_UPLOAD_BYTES: usize = 20 * 1024 * 1024; - -// The soft cap is only safe if it stays under the ingest server's hard -// cap on the *gzipped* body (MRGFY-8124: 25 MiB, enforced by the -// content-length middleware — the same compressed bytes `split_request` -// sizes chunks against). That limit lives in another service, so mirror -// it here and enforce the documented 5 MiB headroom at compile time: a -// future bump that crossed the hard cap breaks the build instead of -// silently 413-dropping every over-cap chunk in production. -const _: () = assert!(MAX_GZIPPED_UPLOAD_BYTES + 5 * 1024 * 1024 <= 25 * 1024 * 1024); - -fn endpoint_url(api_url: &str, repository: &str) -> String { - // The shape `/v1/repos///ci/traces` - // matches the Python implementation. The repository segment is - // pre-validated by `detector::split_owner_repo` at the CLI - // boundary, so we can interpolate it without further escaping. - let trimmed = api_url.trim_end_matches('/'); - format!("{trimmed}{ENDPOINT_PATH}{repository}{ENDPOINT_SUFFIX}") -} - -pub(crate) fn gzip(bytes: &[u8]) -> Result, std::io::Error> { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder.write_all(bytes)?; - encoder.finish() -} - -/// Encode `request` to OTLP/HTTP/protobuf, gzip it, and POST. -/// -/// `client` is passed in so callers can configure timeouts, TLS, -/// or test-time wiremock interception once and reuse it for both -/// the quarantine API and the OTLP endpoint (Phase C will share -/// a single `reqwest::Client`). -pub async fn upload( - client: &reqwest::Client, - api_url: &str, - token: &str, - repository: &str, - request: &ExportTraceServiceRequest, -) -> Result<(), UploadError> { - if request.resource_spans.is_empty() { - // Match Python's `upload()` short-circuit: no spans, no - // request. The backend would 400 anyway, so we save a - // round trip. - return Ok(()); - } - - let encoded = request.encode_to_vec(); - let compressed = gzip(&encoded).map_err(|e| UploadError { - status: None, - message: format!("failed to gzip OTLP payload: {e}"), - })?; - - upload_compressed(client, api_url, token, repository, compressed).await -} - -/// POST an already-gzipped OTLP payload. The splitting path -/// ([`crate::junit_process::split`]) hands over the exact bytes it -/// means to send — gzipped once when the chunk ships as it was sized, -/// once more when it was stamped with its position in the session — -/// so nothing is compressed again here. Callers guarantee -/// `compressed` is a non-empty gzipped `ExportTraceServiceRequest`. -pub async fn upload_compressed( - client: &reqwest::Client, - api_url: &str, - token: &str, - repository: &str, - compressed: Vec, -) -> Result<(), UploadError> { - let url = endpoint_url(api_url, repository); - - let resp = client - .post(&url) - .bearer_auth(token) - .header("Content-Type", "application/x-protobuf") - .header("Content-Encoding", "gzip") - .body(compressed) - .send() - .await - .map_err(|e| UploadError { - status: None, - message: e.to_string(), - })?; - - if resp.status().is_success() { - return Ok(()); - } - let status = resp.status().as_u16(); - let body = resp - .text() - .await - .unwrap_or_else(|e| format!("")); - Err(UploadError { - status: Some(status), - message: body, - }) -} - -/// Build a `reqwest::Client` with a sensible per-request timeout -/// for OTLP uploads. The default `reqwest` timeout is unlimited, -/// which can hang CI for an hour if the backend is slow. -#[must_use] -pub fn default_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("rustls reqwest client builds with default config") -} - -#[cfg(test)] -mod tests { - use super::*; - use flate2::read::GzDecoder; - use opentelemetry_proto::tonic::resource::v1::Resource; - use opentelemetry_proto::tonic::trace::v1::{ResourceSpans, ScopeSpans, Span}; - use std::io::Read as _; - use wiremock::matchers::{header, method, path}; - use wiremock::{Mock, MockServer, Request as MockRequest, ResponseTemplate}; - - fn sample_request() -> ExportTraceServiceRequest { - ExportTraceServiceRequest { - resource_spans: vec![ResourceSpans { - resource: Some(Resource { - attributes: Vec::new(), - dropped_attributes_count: 0, - entity_refs: Vec::new(), - }), - scope_spans: vec![ScopeSpans { - scope: None, - spans: vec![Span { - name: "x".into(), - ..Span::default() - }], - schema_url: String::new(), - }], - schema_url: String::new(), - }], - } - } - - #[test] - fn endpoint_url_matches_python_layout() { - assert_eq!( - endpoint_url("https://api.mergify.com", "owner/repo"), - "https://api.mergify.com/v1/repos/owner/repo/ci/traces" - ); - // Trailing slash on api_url must not produce a double slash. - assert_eq!( - endpoint_url("https://api.mergify.com/", "owner/repo"), - "https://api.mergify.com/v1/repos/owner/repo/ci/traces" - ); - } - - #[tokio::test] - async fn empty_request_skips_http_round_trip() { - // No spans → no request. If the function tries to POST, - // it'll fail because the URL is bogus. - let client = reqwest::Client::new(); - let request = ExportTraceServiceRequest { - resource_spans: Vec::new(), - }; - upload( - &client, - "http://127.0.0.1:1", // refused if actually hit - "token", - "owner/repo", - &request, - ) - .await - .expect("empty request must short-circuit"); - } - - #[tokio::test] - async fn posts_gzipped_protobuf_to_traces_endpoint() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .and(header("Authorization", "Bearer secret")) - .and(header("Content-Type", "application/x-protobuf")) - .and(header("Content-Encoding", "gzip")) - .respond_with(|req: &MockRequest| { - // Decode the body to assert it's valid gzip-protobuf - // round-tripping back into the same request shape. - let mut decoder = GzDecoder::new(req.body.as_slice()); - let mut unzipped = Vec::new(); - decoder - .read_to_end(&mut unzipped) - .expect("body decompresses"); - let decoded_req = ExportTraceServiceRequest::decode(unzipped.as_slice()) - .expect("body decodes to OTLP request"); - assert_eq!(decoded_req.resource_spans.len(), 1); - ResponseTemplate::new(200) - }) - .mount(&server) - .await; - - let client = default_client(); - upload( - &client, - &server.uri(), - "secret", - "owner/repo", - &sample_request(), - ) - .await - .expect("upload succeeds"); - } - - #[test] - fn rejections_are_4xx_except_timeout_and_throttling() { - let err = |status: Option| UploadError { - status, - message: String::new(), - }; - // Permanent rejections: the server understood the request - // and refused it — retrying or waiting won't help. - assert!(err(Some(400)).is_rejection()); - assert!(err(Some(401)).is_rejection()); - assert!(err(Some(403)).is_rejection()); - assert!(err(Some(404)).is_rejection()); - // Transient per OTLP retryability semantics. - assert!(!err(Some(408)).is_rejection()); - assert!(!err(Some(429)).is_rejection()); - assert!(!err(Some(500)).is_rejection()); - assert!(!err(Some(503)).is_rejection()); - // No status at all — network error, timeout, gzip failure. - assert!(!err(None).is_rejection()); - } - - #[tokio::test] - async fn surfaces_http_error_status_and_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/ci/traces")) - .respond_with(ResponseTemplate::new(401).set_body_string("bad token")) - .mount(&server) - .await; - - let client = default_client(); - let err = upload( - &client, - &server.uri(), - "wrong", - "owner/repo", - &sample_request(), - ) - .await - .expect_err("401 must surface as UploadError"); - assert_eq!(err.status, Some(401)); - assert!(err.message.contains("bad token"), "got: {}", err.message); - let rendered = err.to_string(); - // The Display impl is what Python prints; match the wording - // so existing log scrapers / docs don't drift. - assert!( - rendered.contains("code: 401") && rendered.contains("reason: bad token"), - "got: {rendered}" - ); - } -} diff --git a/crates/mergify-ci/src/lib.rs b/crates/mergify-ci/src/lib.rs deleted file mode 100644 index 38ede3f1..00000000 --- a/crates/mergify-ci/src/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Native Rust implementation of the `mergify ci` subcommands. -//! -//! `ci scopes-send` was the first ported command. This module -//! adds `ci git-refs` (base/head detection) and the two shared -//! helpers it depends on: `github_event` (GitHub Actions event -//! payload deserialization) and `queue_metadata` (MQ YAML -//! fenced-block extraction). Subsequent ports (`queue-info`, -//! `junit-process`, `scopes` outer command) reuse these helpers. - -pub mod detector; -mod git; -pub mod git_refs; -pub mod github_event; -mod github_output; -pub mod junit_process; -pub mod queue_info; -pub mod queue_metadata; -pub mod scopes_detect; -pub mod scopes_send; -pub mod tests_quarantine; -pub mod tests_show; - -#[cfg(test)] -mod testing; diff --git a/crates/mergify-ci/src/queue_info.rs b/crates/mergify-ci/src/queue_info.rs deleted file mode 100644 index 7c2b845a..00000000 --- a/crates/mergify-ci/src/queue_info.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! `mergify ci queue-info` — print the current build's merge-queue -//! batch metadata, read from a git note. -//! -//! The engine attaches the batch metadata (a `TrainInfo` YAML dump) to -//! the merge-queue branch head commit as a git note under -//! `refs/notes/mergify/`, precisely so CI providers can read -//! it with plain git and no GitHub token. This command reads that note -//! for the current `HEAD`, so it works in any CI (GitHub Actions, -//! GitLab, `CircleCI`, Jenkins, ...) wherever the merge-queue branch is -//! checked out. It exits with `INVALID_STATE` when no note is found — -//! i.e. the build is not a merge-queue batch. -//! -//! The note's full payload is emitted verbatim as pretty-printed JSON -//! on stdout — every field the engine wrote, including ones this CLI -//! doesn't model — so new engine attributes show up without a CLI -//! change. When `$GITHUB_OUTPUT` is set (GitHub Actions runner) the -//! command also appends it as `queue_metadata` under a random -//! `ghadelimiter_` heredoc, matching the pattern the workflow -//! runtime expects for multi-line outputs. - -use std::io::Write; - -use mergify_core::CliError; -use mergify_core::Output; -use serde_json::Value; - -/// Reads the merge-queue git note attached to the current `HEAD`, -/// returning its full payload. -/// -/// The real implementation shells out to `git`; tests inject a stub so -/// the command can be exercised without a checkout. -pub type HeadNoteReader<'a> = &'a dyn Fn() -> Option; - -/// Run the `ci queue-info` command. -pub fn run(output: &mut dyn Output) -> Result<(), CliError> { - run_with_reader(output, &real_head_note_reader) -} - -/// Inner entrypoint with the git-note reader injected so tests can run -/// without a real repository. -fn run_with_reader( - output: &mut dyn Output, - head_note_reader: HeadNoteReader<'_>, -) -> Result<(), CliError> { - let Some(metadata) = head_note_reader() else { - return Err(CliError::InvalidState( - "No merge queue metadata found. queue-info reads the \ - refs/notes/mergify/ git note the engine attaches to a \ - merge queue draft branch head; run it on a merge queue batch \ - build." - .to_string(), - )); - }; - - emit_json(output, &metadata)?; - write_github_output(&metadata)?; - Ok(()) -} - -/// Production [`HeadNoteReader`]: read the merge-queue note attached to -/// the current `HEAD` commit using only plain git, no GitHub token. -/// -/// The engine publishes the batch metadata as a git note under -/// `refs/notes/mergify/` attached to the MQ branch head -/// commit. In CI the working tree is checked out at that commit, so we -/// fetch every `refs/notes/mergify/*` ref and return the note that is -/// attached to `HEAD`. Enumerating the refs rather than deriving the -/// branch name keeps this working under a detached `HEAD` (how most -/// non-GitHub CIs check out a revision) and needs no CI-specific env. -/// -/// Any git failure (no remote, notes never published, not a repo, a -/// shallow clone that hid the commit) is swallowed as `None` so the -/// caller surfaces `INVALID_STATE`. -#[must_use] -pub fn real_head_note_reader() -> Option { - // Notes aren't fetched by default, so pull them ourselves. The - // wildcard refspec mirrors the engine's `refs/notes/mergify/*` - // namespace; `+` force-updates so a re-queued branch's note wins. - if !crate::git::succeeds(&[ - "fetch", - "--no-tags", - "--quiet", - "origin", - "+refs/notes/mergify/*:refs/notes/mergify/*", - ]) { - return None; - } - let refs = - crate::git::capture(&["for-each-ref", "--format=%(refname)", "refs/notes/mergify/"])?; - refs.lines() - .filter(|r| !r.is_empty()) - .find_map(|notes_ref| crate::git::read_note(notes_ref, "HEAD")) -} - -fn emit_json(output: &mut dyn Output, metadata: &Value) -> std::io::Result<()> { - output.emit(metadata, &mut |w: &mut dyn Write| { - let rendered = serde_json::to_string_pretty(metadata) - .map_err(|e| std::io::Error::other(e.to_string()))?; - writeln!(w, "{rendered}") - }) -} - -fn write_github_output(metadata: &Value) -> Result<(), CliError> { - let compact = serde_json::to_string(metadata) - .map_err(|e| CliError::Generic(format!("failed to serialize queue metadata: {e}")))?; - crate::github_output::append(&[("queue_metadata", &compact)]) -} - -#[cfg(test)] -mod tests { - use mergify_core::ExitCode; - use mergify_core::env; - use mergify_test_support::Captured; - use serde_json::json; - - use super::*; - - /// A note carrying fields the CLI doesn't model (`scopes`, - /// per-PR `scopes`) — they must still reach the output. - fn sample() -> Value { - json!({ - "checking_base_sha": "abc123", - "pull_requests": [{"number": 10, "scopes": ["backend"]}], - "previous_failed_batches": [], - "scopes": ["backend"], - }) - } - - fn no_note() -> Option { - None - } - - #[test] - fn errors_when_no_note() { - let mut cap = Captured::human(); - let err = run_with_reader(&mut cap.output, &no_note).unwrap_err(); - assert!(matches!(err, CliError::InvalidState(_))); - assert_eq!(err.exit_code(), ExitCode::InvalidState); - } - - #[test] - fn prints_whole_note_payload() { - let note = || Some(sample()); - let mut cap = Captured::human(); - env::testing::with_var("GITHUB_OUTPUT", None::<&str>, || { - run_with_reader(&mut cap.output, ¬e).unwrap(); - }); - let stdout = cap.stdout(); - assert!(stdout.contains("\"checking_base_sha\": \"abc123\"")); - assert!(stdout.contains("\"number\": 10")); - // Fields the CLI doesn't model are passed through verbatim. - assert!(stdout.contains("\"scopes\""), "scopes missing: {stdout}"); - assert!( - stdout.contains("\"backend\""), - "scope value missing: {stdout}" - ); - } - - #[test] - fn appends_to_github_output_when_set() { - let dir = tempfile::tempdir().unwrap(); - let gha_output = dir.path().join("gha_output"); - let note = || Some(sample()); - let mut cap = Captured::human(); - env::testing::with_var("GITHUB_OUTPUT", Some(gha_output.to_str().unwrap()), || { - run_with_reader(&mut cap.output, ¬e).unwrap(); - }); - let written = std::fs::read_to_string(&gha_output).unwrap(); - assert!(written.starts_with("queue_metadata< `batch_pr_number`, and it -//! dual-emits both for a deprecation window). A struct with required -//! fields would fail the *whole document* over one renamed key it -//! never even reads, and the caller's `.ok()` would swallow that into -//! `None` — silently costing `git_refs` the `checking_base_sha` it -//! came for and sending CI diffs against the wrong base. Deserializing -//! only what is read keeps the CLI working against any engine version. - -use mergify_core::Output; - -use crate::github_event::GitHubEvent; -#[cfg(doc)] -use crate::github_event::PullRequest; - -/// Parse the first ```yaml``` fenced block out of `body` into a -/// generic value, keeping every field. Returns `None` when the body -/// has no fenced block, or the block isn't a YAML mapping (a bare -/// scalar or sequence isn't merge-queue metadata). -#[must_use] -pub fn parse_yaml_block(body: &str) -> Option { - let mut inside = false; - let mut lines: Vec<&str> = Vec::new(); - for line in body.lines() { - if !inside { - if line.starts_with("```yaml") { - inside = true; - } - } else if line.starts_with("```") { - break; - } else { - lines.push(line); - } - } - if lines.is_empty() { - return None; - } - // Same parse as the git-note reader — the engine writes this - // identical payload to both the note and the PR body. - crate::git::parse_yaml_mapping(&lines.join("\n")) -} - -/// Extract MQ metadata from an event payload's pull-request body. -/// -/// The title only nominates the pull request: the body is read when -/// [`PullRequest::is_from_base_repository`] also holds. Emits a warning -/// on `output` (stderr for human mode) when it does not, or when the -/// body is missing or lacks the fenced block. -pub fn extract_from_event( - ev: &GitHubEvent, - output: &mut dyn Output, -) -> std::io::Result> { - let Some(pr) = &ev.pull_request else { - return Ok(None); - }; - let Some(title) = pr.title.as_deref() else { - return Ok(None); - }; - if !title.starts_with("merge queue: ") { - return Ok(None); - } - if !pr.is_from_base_repository() { - output.status( - "WARNING: pull request titled like a merge queue draft does not come from the base repository, skipping metadata extraction", - )?; - return Ok(None); - } - let Some(body) = pr.body.as_deref() else { - output.status("WARNING: MQ pull request without body, skipping metadata extraction")?; - return Ok(None); - }; - let parsed = parse_yaml_block(body); - if parsed.is_none() { - output.status( - "WARNING: MQ pull request body without Mergify metadata, skipping metadata extraction", - )?; - } - Ok(parsed) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - - use super::*; - - #[test] - fn parse_yaml_block_extracts_metadata() { - let body = "prelude\n\n```yaml\nchecking_base_sha: abc\npull_requests:\n - number: 1\n```\ntrailing"; - let meta = parse_yaml_block(body).unwrap(); - assert_eq!(meta["checking_base_sha"], "abc"); - assert_eq!(meta["pull_requests"][0]["number"], 1); - } - - #[test] - fn parse_yaml_block_returns_none_without_block() { - assert!(parse_yaml_block("just text").is_none()); - } - - #[test] - fn parse_yaml_block_rejects_non_mapping() { - // A bare scalar or sequence isn't merge-queue metadata; the - // caller warns instead of treating it as an empty payload. - assert!(parse_yaml_block("```yaml\njust a scalar\n```").is_none()); - assert!(parse_yaml_block("```yaml\n- a\n- b\n```").is_none()); - } - - #[test] - fn parse_yaml_block_survives_every_batch_pr_key_spelling() { - // The engine renamed `draft_pr_number` -> `batch_pr_number` - // and dual-emits both for a deprecation window. We never read - // the key, so all three spellings must yield the same - // `checking_base_sha` and none may fail the document. Modelling - // this block with a required field once made a *renamed* key - // silently cost us the base SHA entirely. - for batch_keys in [ - "draft_pr_number: 42", - "batch_pr_number: 42", - "batch_pr_number: 42\n draft_pr_number: 42", - ] { - let body = format!( - "prelude\n```yaml\nchecking_base_sha: cafef00d\nprevious_failed_batches:\n - {batch_keys}\n checked_pull_requests:\n - 7\n```" - ); - let meta = parse_yaml_block(&body) - .unwrap_or_else(|| panic!("should parse with {batch_keys:?}")); - assert_eq!(meta["checking_base_sha"], "cafef00d"); - } - } - - #[test] - fn parse_yaml_block_survives_unknown_and_missing_keys() { - // Same invariant, generalised: a key the engine adds later, and - // an entry missing keys we used to require, both leave - // `checking_base_sha` reachable. - let body = "```yaml\nchecking_base_sha: abc\nsomething_new: 1\nprevious_failed_batches:\n - {}\n```"; - let meta = parse_yaml_block(body).unwrap(); - assert_eq!(meta["checking_base_sha"], "abc"); - } - - #[test] - fn extract_ignores_non_mq_pr() { - let ev = GitHubEvent { - pull_request: Some(crate::github_event::PullRequest { - title: Some("feat: something".into()), - ..Default::default() - }), - ..Default::default() - }; - let mut cap = Captured::human(); - let result = extract_from_event(&ev, &mut cap.output).unwrap(); - assert!(result.is_none()); - assert!(cap.stderr().is_empty()); - } - - /// A `merge queue: ` pull request whose head and base live in the - /// repositories with these ids (`None`: the payload's `repo` is - /// `null`, as on the head of a deleted fork). - fn mq_event(head_repo: Option, base_repo: Option, body: Option<&str>) -> GitHubEvent { - let git_ref = |id: Option| crate::github_event::GitRef { - repo: id.map(|id| crate::github_event::RefRepository { id: Some(id) }), - ..Default::default() - }; - GitHubEvent { - pull_request: Some(crate::github_event::PullRequest { - title: Some("merge queue: batch".into()), - body: body.map(Into::into), - head: Some(git_ref(head_repo)), - base: Some(git_ref(base_repo)), - ..Default::default() - }), - ..Default::default() - } - } - - const BODY: &str = "blah\n```yaml\nchecking_base_sha: deadbeef\n```"; - - #[test] - fn extract_warns_on_mq_pr_without_body() { - let ev = mq_event(Some(1), Some(1), None); - let mut cap = Captured::human(); - let result = extract_from_event(&ev, &mut cap.output).unwrap(); - assert!(result.is_none()); - let stderr = cap.stderr(); - assert!(stderr.contains("without body"), "got: {stderr:?}"); - } - - #[test] - fn extract_returns_metadata_for_mq_pr() { - let ev = mq_event(Some(1), Some(1), Some(BODY)); - let mut cap = Captured::human(); - let meta = extract_from_event(&ev, &mut cap.output).unwrap().unwrap(); - assert_eq!(meta["checking_base_sha"], "deadbeef"); - } - - #[test] - fn extract_ignores_mq_title_outside_the_base_repository() { - // MRGFY-8854: anyone can title a pull request `merge queue: `. - // From a fork, from a deleted fork, or with no repository to - // compare, the body is not the engine's. - for (head, base) in [ - (Some(2), Some(1)), - (None, Some(1)), - (Some(1), None), - (None, None), - ] { - let ev = mq_event(head, base, Some(BODY)); - let mut cap = Captured::human(); - let result = extract_from_event(&ev, &mut cap.output).unwrap(); - assert!(result.is_none(), "{head:?} -> {base:?} admitted"); - let stderr = cap.stderr(); - assert!( - stderr.contains("does not come from the base repository"), - "got: {stderr:?}" - ); - } - } -} diff --git a/crates/mergify-ci/src/scopes_detect/changed_files.rs b/crates/mergify-ci/src/scopes_detect/changed_files.rs deleted file mode 100644 index 4983f25f..00000000 --- a/crates/mergify-ci/src/scopes_detect/changed_files.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! `git diff` between two refs, with progressive deepening of a -//! shallow clone if no merge base exists yet. -//! -//! The history deepening is necessary in CI: GitHub Actions -//! checkouts default to depth=1, and the merge base between -//! `base` and `head` probably lives further back. We fetch in -//! batches of 100 commits until either a merge base appears or -//! the commit count stops growing (meaning we've reached the -//! root and there's genuinely no common ancestor). - -use std::path::Path; -use std::process::Command; - -use mergify_core::CliError; - -/// Scoped namespace for refs we fetch ourselves, to avoid -/// clashing with `refs/remotes/origin/*` (which may not exist or -/// may point elsewhere). -const FETCHED_REF_PREFIX: &str = "refs/mergify-cli/fetched/"; - -const COMMITS_BATCH_SIZE: u64 = 100; - -/// The remote `ensure_history` deepens from. -const REMOTE: &str = "origin"; - -fn is_sha(ref_: &str) -> bool { - // Only full 40-char SHAs — abbreviated SHAs would false-match - // branch names like "deadbeef" and cause `git fetch` to treat - // them as branches. - ref_.len() == 40 - && ref_ - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) -} - -fn is_local_ref(ref_: &str) -> bool { - ref_ == "HEAD" || ref_.starts_with("HEAD~") || ref_.starts_with("HEAD^") -} - -fn local_ref(ref_: &str) -> String { - if is_sha(ref_) || is_local_ref(ref_) { - ref_.to_string() - } else { - format!("{FETCHED_REF_PREFIX}{ref_}") - } -} - -fn fetch_arg(ref_: &str) -> Option { - if is_local_ref(ref_) { - None - } else if is_sha(ref_) { - Some(ref_.to_string()) - } else { - // `git fetch origin ` only updates `FETCH_HEAD`; - // use an explicit refspec so the branch becomes a real - // local ref we can name later. - Some(format!("+{}:{}", remote_name(ref_), local_ref(ref_))) - } -} - -/// The name `REMOTE` knows `ref_` by. A remote-tracking name like -/// `origin/main` exists only in a clone, so asking the remote for it -/// fails with "couldn't find remote ref". That is what a depth-1 -/// CI checkout runs into, as it has no `refs/remotes/origin/main` -/// to resolve locally either. Only the `origin/` prefix is -/// stripped: `feature/x` is a branch, not a remote. -fn remote_name(ref_: &str) -> &str { - ref_.strip_prefix("refs/remotes/") - .or_else(|| ref_.strip_prefix("remotes/")) - .unwrap_or(ref_) - .strip_prefix(REMOTE) - .and_then(|rest| rest.strip_prefix('/')) - .unwrap_or(ref_) -} - -/// Base `git` command, rooted at `repo_dir` via `-C` when one is -/// given. Production always passes `None` (the process cwd); the -/// parameter is what lets the tests below drive a fixture -/// repository without `std::env::set_current_dir`, which races -/// with parallel cargo test workers. Same shape as -/// `mergify_stack::git::git_cmd`, C locale included — `run_git` -/// quotes git's stderr into its error, and a translated message -/// would leave a French sentence inside an English CLI error. -fn git_cmd(repo_dir: Option<&Path>) -> Command { - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - cmd.env("LC_ALL", "C").env("LANG", "C").env("LANGUAGE", "C"); - cmd -} - -fn run_git(repo_dir: Option<&Path>, args: &[&str]) -> Result { - let out = git_cmd(repo_dir) - .args(args) - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git {args:?}`: {e}")))?; - if !out.status.success() { - let stderr = String::from_utf8_lossy(&out.stderr); - return Err(CliError::Generic(format!( - "`git {}` failed ({}): {}", - args.join(" "), - out.status, - stderr.trim(), - ))); - } - // Untrimmed: `git_changed_files` reads NUL-delimited paths, - // and a leading or trailing space is a legal filename - // character. Callers that parse a scalar trim it themselves. - // - // Lossy on purpose: see `git_changed_files` for why a path - // that is not UTF-8 must come back with U+FFFD in it rather - // than as raw bytes or an error. - Ok(String::from_utf8_lossy(&out.stdout).into_owned()) -} - -fn has_merge_base(repo_dir: Option<&Path>, base: &str, head: &str) -> bool { - git_cmd(repo_dir) - .args(["merge-base", "--", base, head]) - .output() - .is_ok_and(|o| o.status.success()) -} - -/// The commits a shallow clone's history stops at, empty for a full -/// clone. Read from the `shallow` file rather than asked of -/// `rev-parse --is-shallow-repository`, which git before 2.15 -/// echoes back instead of answering. -fn shallow_boundary(repo_dir: Option<&Path>) -> Result, CliError> { - let path = run_git(repo_dir, &["rev-parse", "--git-path", "shallow"])?; - let path = Path::new(path.trim()); - let path = repo_dir.map_or_else(|| path.to_path_buf(), |dir| dir.join(path)); - match std::fs::read_to_string(&path) { - Ok(content) => Ok(content.lines().map(str::to_string).collect()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(e) => Err(CliError::wrap( - format!("could not read {}", path.display()), - e, - )), - } -} - -fn is_ancestor(repo_dir: Option<&Path>, commit: &str, of: &str) -> bool { - git_cmd(repo_dir) - .args(["merge-base", "--is-ancestor", commit, of]) - .output() - .is_ok_and(|o| o.status.success()) -} - -fn commits_count(repo_dir: Option<&Path>) -> Result { - let out = run_git(repo_dir, &["rev-list", "--count", "--all"])?; - let count = out.trim(); - count - .parse::() - .map_err(|e| CliError::Generic(format!("could not parse commit count {count:?}: {e}"))) -} - -fn fetch(repo_dir: Option<&Path>, depth_flag: &str, fetch_args: &[String]) -> Result<(), CliError> { - let mut args: Vec<&str> = vec!["fetch", "--no-tags", depth_flag, REMOTE]; - if !fetch_args.is_empty() { - args.push("--"); - for fa in fetch_args { - args.push(fa); - } - } - run_git(repo_dir, &args).map(drop) -} - -/// Deepen the local clone until `base` and `head` share an -/// ancestor (or until we've exhausted history). Returns the -/// pair of local ref names (`refs/mergify-cli/fetched/` for -/// remote names; `HEAD~N` / `HEAD^N` / SHA passed through -/// untouched) that the subsequent `git diff` should target. -pub fn ensure_history( - repo_dir: Option<&Path>, - base: &str, - head: &str, -) -> Result<(String, String), CliError> { - if has_merge_base(repo_dir, base, head) { - return Ok((base.to_string(), head.to_string())); - } - - let fetch_args: Vec = [fetch_arg(base), fetch_arg(head)] - .into_iter() - .flatten() - .collect(); - let local_base = local_ref(base); - let local_head = local_ref(head); - let mut depth = COMMITS_BATCH_SIZE; - - fetch(repo_dir, &format!("--depth={depth}"), &fetch_args)?; - - let mut last_count = commits_count(repo_dir)?; - while !has_merge_base(repo_dir, &local_base, &local_head) { - depth = depth.saturating_mul(2); - // `--deepen` only moves the boundary of what the fetch asks - // for. A HEAD-relative ref asks for nothing, so a depth-1 - // checkout's HEAD would keep hiding its parents and no merge - // base would ever appear. Asking for every boundary commit - // deepens it too. Only the ones under `base` or `head`: the - // `shallow` file also holds the boundary of anything - // shallow-fetched from another remote, which `origin` may - // not have and would refuse the whole fetch over. Those left - // came from the remote, unlike a local commit on top of the - // checkout, which it would refuse too. - let mut deepen_args = fetch_args.clone(); - deepen_args.extend(shallow_boundary(repo_dir)?.into_iter().filter(|commit| { - is_ancestor(repo_dir, commit, &local_base) || is_ancestor(repo_dir, commit, &local_head) - })); - fetch(repo_dir, &format!("--deepen={depth}"), &deepen_args)?; - let count = commits_count(repo_dir)?; - if count == last_count { - // No new commits this round — we've reached the root - // and the refs genuinely have no common ancestor. - if !has_merge_base(repo_dir, &local_base, &local_head) { - return Err(CliError::Generic(format!( - "cannot find a common ancestor between {base} and {head}", - ))); - } - break; - } - last_count = count; - } - - Ok((local_base, local_head)) -} - -/// Names of files changed between `base` and `head`. Uses the -/// `base...head` (three-dot) diff to compare `head` against -/// `merge-base(base, head)`, which is what CI scope detection -/// wants: "files this branch touched on top of trunk." -/// -/// A rename yields **both** of its paths, so a scope the file moved -/// out of still counts as touched. Paths are not -/// `core.quotePath`-escaped, so the globs see the real name, and -/// the result is unescaped, so callers that print a path must -/// escape it first. -/// -/// A path that is not valid UTF-8 (a Latin-1 or Shift-JIS name) -/// comes back with each invalid sequence replaced by U+FFFD: -/// `caf\xe9.txt` reads `caf\u{FFFD}.txt`. That is the name the -/// engine matches, not a corruption of it. The engine derives -/// scopes from GitHub's pull request files API, which reports that -/// exact replacement (checked on a sandbox repository holding -/// `caf\xe9.txt` and a Shift-JIS name, MRGFY-8289). An error would -/// fail CI on a pull request the engine scopes without complaint, -/// and no pattern in the UTF-8 YAML config can spell the raw bytes. -/// `?` and `[...]` match that U+FFFD as one character, as the -/// engine does. -pub fn git_changed_files( - repo_dir: Option<&Path>, - base: &str, - head: &str, -) -> Result, CliError> { - let (local_base, local_head) = ensure_history(repo_dir, base, head)?; - let range = format!("{local_base}...{local_head}"); - // `--no-renames` is what gives a rename both of its paths: git - // emits `D ` + `A ` rather than pairing - // them into one `R` entry, whose `--name-only` line carries the - // destination alone. The engine treats a rename the same way, - // added-at-destination and removed-at-source (MRGFY-8248); the - // two must agree or CLI-uploaded and engine-derived scopes - // disagree for the same pull request. - // - // `--diff-filter=ACMRTD` keeps Added, Modified, - // Type-changed, Deleted, plus the Renamed and Copied letters - // that `--no-renames` has just made unreachable. They stay so - // that removing `--no-renames` degrades to the old - // destination-only behavior rather than to reporting nothing. - // Excludes Unmerged (U), Unknown (X), Broken (B). - // - // `diff.relative` is forced off: set in a repo's config, it - // reports paths relative to the process cwd, so running - // `mergify ci scopes` from a subdirectory would hand the - // globs `r.md` for `docs/r.md` and drop every path outside - // that subtree. Scope globs are anchored to the repo root, so - // nothing would match and every scope-gated job would be - // skipped. Overridden through `-c` rather than - // `--no-relative`, which only exists since git 2.28. - // - // `-z` suppresses the `core.quotePath` escaping git otherwise - // applies to any path holding a non-ASCII byte, a quote, or a - // backslash — line-based output hands the scope globs - // `"critical/cach\303\251.txt"`, quotes included, and nothing - // matches it (MRGFY-8287). Setting `core.quotePath=false` - // would not do: git escapes `"` and `\` whatever that says. - // `-z` also removes the only reason a path could not contain - // a newline, which is why the printers in `super` escape - // before echoing one. - let out = run_git( - repo_dir, - &[ - "-c", - "diff.relative=false", - "diff", - "--name-only", - "-z", - "--no-renames", - "--diff-filter=ACMRTD", - &range, - "--", - ], - )?; - Ok(out - .split('\0') - .filter(|path| !path.is_empty()) - .map(ToString::to_string) - .collect()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write as _; - use std::process::Stdio; - - /// Run `git` in `dir` with the developer's global/system - /// config out of the way, so a personal `diff.renames` or - /// `core.quotePath` setting can't change what these tests see. - /// - /// `GIT_DIR`, `GIT_WORK_TREE` and the other repository-locating - /// variables are dropped too: they override `-C`, and these - /// helpers run `add`/`commit`. Under `git bisect run cargo - /// test` (or a hook, or `git rebase -x`) they are set, and the - /// fixture's `git add -A` would otherwise stage into the - /// developer's real repository. - fn fixture_git(dir: &Path) -> Command { - let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(dir) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1"); - for var in [ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_COMMON_DIR", - ] { - cmd.env_remove(var); - } - cmd - } - - fn git(dir: &Path, args: &[&str]) { - let ok = fixture_git(dir) - .args(args) - .status() - .expect("spawn git") - .success(); - assert!(ok, "git {args:?} failed"); - } - - /// Stage `content` at a path given as raw bytes, which need - /// not be UTF-8. Goes through the index because macOS refuses - /// such a name on disk. - fn stage_raw_path(dir: &Path, path: &[u8], content: &str) { - let mut hash = fixture_git(dir) - .args(["hash-object", "-w", "--stdin"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .expect("spawn git hash-object"); - hash.stdin - .take() - .expect("stdin") - .write_all(content.as_bytes()) - .expect("write blob"); - let out = hash.wait_with_output().expect("wait git hash-object"); - assert!(out.status.success(), "git hash-object failed"); - let blob = String::from_utf8(out.stdout).expect("hex object id"); - - let mut entry = format!("100644 {} 0\t", blob.trim()).into_bytes(); - entry.extend_from_slice(path); - entry.push(b'\n'); - let mut index = fixture_git(dir) - .args(["update-index", "--index-info"]) - .stdin(Stdio::piped()) - .spawn() - .expect("spawn git update-index"); - index - .stdin - .take() - .expect("stdin") - .write_all(&entry) - .expect("write index entry"); - assert!(index.wait().expect("wait git update-index").success()); - } - - fn write(dir: &Path, rel: &str, content: &str) { - let path = dir.join(rel); - std::fs::create_dir_all(path.parent().expect("path has a parent")).expect("mkdir"); - std::fs::write(path, content).expect("write"); - } - - /// A repo on `main` holding `critical/` and `docs/readme.md`, - /// checked out on `feature` so the caller can commit its change - /// there. `ignored/` is made in the working tree afterwards and - /// stays untracked — git has no empty directories, and the - /// `git mv` case needs its destination to exist. - /// - /// `diff.renames` and `core.quotePath` are pinned to git's - /// defaults in the repo-local config, which beats whatever the - /// developer or CI runner has globally: each test below guards - /// a behavior that only misfires when the corresponding - /// default is in force. - fn fixture() -> tempfile::TempDir { - let tmp = tempfile::tempdir().expect("tempdir"); - let dir = tmp.path(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["config", "diff.renames", "true"], - &["config", "core.quotePath", "true"], - ] { - git(dir, args); - } - write(dir, "critical/guard.txt", "guard\n"); - write(dir, "critical/caché.txt", "accented\n"); - write(dir, "critical/quote\"and\\slash.txt", "punctuation\n"); - write(dir, "docs/readme.md", "docs\n"); - git(dir, &["add", "-A"]); - git(dir, &["commit", "-q", "-m", "base"]); - git(dir, &["checkout", "-q", "-b", "feature"]); - std::fs::create_dir(dir.join("ignored")).expect("mkdir ignored"); - tmp - } - - /// Sorted, because `git_changed_files` promises a set of paths - /// and not an order — git's own ordering is steered by - /// `diff.orderFile`, which the production call inherits from - /// the developer's global config. - fn changed(dir: &Path) -> Vec { - let mut paths = git_changed_files(Some(dir), "main", "HEAD").expect("git diff"); - paths.sort(); - paths - } - - #[test] - fn rename_yields_source_and_destination() { - // MRGFY-8286: with rename detection on, `--name-only` - // collapsed a `git mv` to the destination alone, so the - // scope the file moved *out of* was never reported and a - // scope-conditioned CI job for it got skipped — even - // though the resulting `critical/` tree is identical to - // the one a plain deletion produces. - let tmp = fixture(); - let dir = tmp.path(); - git(dir, &["mv", "critical/guard.txt", "ignored/guard.txt"]); - git(dir, &["commit", "-q", "-m", "move the guard out"]); - - assert_eq!(changed(dir), ["critical/guard.txt", "ignored/guard.txt"]); - } - - #[test] - fn non_ascii_path_is_not_quote_escaped() { - // MRGFY-8287: `core.quotePath` is on by default, so - // line-based output rendered this path as - // `"critical/cach\303\251.txt"` — quotes and octal escapes - // included — which matches no scope glob, leaving repos - // with accented or CJK filenames detecting nothing. - let tmp = fixture(); - let dir = tmp.path(); - write(dir, "critical/caché.txt", "accented, changed\n"); - git(dir, &["add", "-A"]); - git(dir, &["commit", "-q", "-m", "touch the accented file"]); - - assert_eq!(changed(dir), ["critical/caché.txt"]); - } - - #[test] - fn quote_and_backslash_path_is_not_escaped() { - // The half of MRGFY-8287 that `core.quotePath=false` does - // *not* cover: git escapes `"` and `\` in a path whatever - // that setting says, so only `-z` gets these through. A - // fix that reached for the config instead would pass - // `non_ascii_path_is_not_quote_escaped` and still leave - // this repo silently unscoped. - let tmp = fixture(); - let dir = tmp.path(); - write( - dir, - "critical/quote\"and\\slash.txt", - "punctuation, changed\n", - ); - git(dir, &["add", "-A"]); - git(dir, &["commit", "-q", "-m", "touch the punctuated file"]); - - assert_eq!(changed(dir), ["critical/quote\"and\\slash.txt"]); - } - - #[test] - fn non_utf8_path_reads_as_github_reports_it() { - // MRGFY-8289: GitHub's pull request files API, which the - // engine scopes from, reports a Latin-1 `caf\xe9.txt` as - // `caf\u{FFFD}.txt`, and the Shift-JIS `テスト.txt` with one - // U+FFFD per lead byte. An error here would fail CI on a - // pull request the engine scopes fine. - let tmp = fixture(); - let dir = tmp.path(); - stage_raw_path(dir, b"critical/caf\xe9.txt", "latin-1\n"); - stage_raw_path(dir, b"critical/\x83e\x83X\x83g.txt", "shift-jis\n"); - git(dir, &["commit", "-q", "-m", "add non-UTF-8 names"]); - - assert_eq!( - changed(dir), - [ - "critical/caf\u{FFFD}.txt", - "critical/\u{FFFD}e\u{FFFD}X\u{FFFD}g.txt", - ], - ); - } - - #[test] - fn add_modify_and_delete_are_unaffected() { - // Every ordinary status still reaches the caller. This - // does not pin `--no-renames` — the deleted and added - // files share no content, so git would never pair them - // even with rename detection on; `rename_yields_source_ - // and_destination` is the test that holds the flag down. - let tmp = fixture(); - let dir = tmp.path(); - write(dir, "ignored/new.txt", "new\n"); - write(dir, "docs/readme.md", "docs changed\n"); - std::fs::remove_file(dir.join("critical/guard.txt")).expect("rm"); - git(dir, &["add", "-A"]); - git(dir, &["commit", "-q", "-m", "add, modify, delete"]); - - assert_eq!( - changed(dir), - ["critical/guard.txt", "docs/readme.md", "ignored/new.txt",], - ); - } - - #[test] - fn is_sha_matches_only_full_40char_lowercase_hex() { - assert!(is_sha("0123456789abcdef0123456789abcdef01234567")); - // Uppercase rejected (consistent with Python's regex - // `^[0-9a-f]{40}$`). - assert!(!is_sha("0123456789ABCDEF0123456789ABCDEF01234567")); - // Shorter rejected — abbreviated SHAs would false-match - // branch names like "deadbeef". - assert!(!is_sha("deadbeef")); - // Branch name (non-hex char). - assert!(!is_sha("main")); - } - - #[test] - fn local_ref_namespacing() { - // HEAD-relative refs pass through untouched. - assert_eq!(local_ref("HEAD"), "HEAD"); - assert_eq!(local_ref("HEAD~1"), "HEAD~1"); - assert_eq!(local_ref("HEAD^2"), "HEAD^2"); - // Full SHAs pass through untouched. - let sha = "a".repeat(40); - assert_eq!(local_ref(&sha), sha); - // Branch names get namespaced into our fetched prefix. - assert_eq!(local_ref("main"), format!("{FETCHED_REF_PREFIX}main")); - } - - #[test] - fn fetch_arg_chooses_refspec_for_branches() { - // No fetch needed for HEAD-relative refs. - assert_eq!(fetch_arg("HEAD"), None); - assert_eq!(fetch_arg("HEAD~3"), None); - // SHAs get fetched by SHA directly. - let sha = "b".repeat(40); - assert_eq!(fetch_arg(&sha), Some(sha.clone())); - // Branch names use a refspec so the result lands at a - // local ref name we can target later in `git diff`. - assert_eq!( - fetch_arg("main"), - Some(format!("+main:{FETCHED_REF_PREFIX}main")), - ); - // A remote-tracking name is asked of the remote by its - // branch name, and still lands under its own local name. A - // slash alone is not a remote prefix. - for (ref_, remote) in [ - ("origin/main", "main"), - ("origin/feature/x", "feature/x"), - ("remotes/origin/main", "main"), - ("refs/remotes/origin/main", "main"), - ("feature/x", "feature/x"), - ("originals/x", "originals/x"), - ] { - assert_eq!( - fetch_arg(ref_), - Some(format!("+{remote}:{FETCHED_REF_PREFIX}{ref_}")), - "{ref_}", - ); - } - } - - /// An upstream where `feature` touched `docs/readme.md`, `main` - /// moved on after the fork (so the merge base is the tip of - /// neither), and `pull/merge` merges the two, the commit - /// `actions/checkout` checks out for a pull request. - fn upstream() -> tempfile::TempDir { - let tmp = fixture(); - let up = tmp.path(); - write(up, "docs/readme.md", "docs changed\n"); - git(up, &["add", "-A"]); - git(up, &["commit", "-q", "-m", "touch the docs"]); - git(up, &["checkout", "-q", "main"]); - write(up, "ignored/later.txt", "later\n"); - git(up, &["add", "-A"]); - git(up, &["commit", "-q", "-m", "main moves on"]); - git(up, &["checkout", "-q", "-b", "pull/merge"]); - git(up, &["merge", "-q", "--no-edit", "feature"]); - tmp - } - - /// `branch` of `up`, fetched at `depth` and checked out detached - /// the way `actions/checkout` does it: no `refs/remotes/origin/*`. - fn shallow_clone(up: &Path, branch: &str, depth: u32) -> tempfile::TempDir { - let tmp = tempfile::tempdir().expect("tempdir"); - let dir = tmp.path(); - // `file://`, because git ignores `--depth` for a plain - // local path. - let url = format!("file://{}", up.display()); - for args in [ - &["init", "-q"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["remote", "add", REMOTE, &url], - &[ - "fetch", - "-q", - "--no-tags", - &format!("--depth={depth}"), - REMOTE, - branch, - ], - &["checkout", "-q", "--detach", "FETCH_HEAD"], - ] { - git(dir, args); - } - tmp - } - - #[test] - fn remote_tracking_base_on_a_shallow_clone() { - // MRGFY-8290: on a depth-1 checkout, `--base origin/main` - // failed twice over: the remote has no ref by that name - // ("couldn't find remote ref origin/main"), and once it was - // asked for `main` instead, deepening `main` never lifted - // the shallow boundary sitting on HEAD, so the merge base - // stayed hidden behind it. - let up = upstream(); - let clone = shallow_clone(up.path(), "pull/merge", 1); - - let paths = git_changed_files(Some(clone.path()), "origin/main", "HEAD").expect("git diff"); - assert_eq!(paths, ["docs/readme.md"]); - } - - #[test] - fn local_commit_on_a_shallow_clone() { - // A commit made in CI on top of the checkout is unknown to - // the remote, which refuses the whole fetch if it is asked - // for. Deepening goes through the boundary commit below it. - let up = upstream(); - let clone = shallow_clone(up.path(), "feature", 1); - let dir = clone.path(); - write(dir, "ignored/generated.txt", "generated\n"); - git(dir, &["add", "-A"]); - git(dir, &["commit", "-q", "-m", "local only"]); - - let mut paths = git_changed_files(Some(dir), "origin/main", "HEAD").expect("git diff"); - paths.sort(); - assert_eq!(paths, ["docs/readme.md", "ignored/generated.txt"]); - } - - #[test] - fn unrelated_shallow_remote_on_a_shallow_clone() { - // The `shallow` file is per repository, not per remote. A - // boundary that came from another remote is unknown to - // `origin`, which would refuse the whole deepening fetch if - // it were asked for it. - let up = upstream(); - let clone = shallow_clone(up.path(), "pull/merge", 1); - let dir = clone.path(); - let other = fixture(); - write(other.path(), "ignored/other.txt", "other\n"); - git(other.path(), &["add", "-A"]); - git(other.path(), &["commit", "-q", "-m", "only on other"]); - let url = format!("file://{}", other.path().display()); - git(dir, &["remote", "add", "other", &url]); - git( - dir, - &["fetch", "-q", "--no-tags", "--depth=1", "other", "feature"], - ); - - let paths = git_changed_files(Some(dir), "origin/main", "HEAD").expect("git diff"); - assert_eq!(paths, ["docs/readme.md"]); - } -} diff --git a/crates/mergify-ci/src/scopes_detect/config.rs b/crates/mergify-ci/src/scopes_detect/config.rs deleted file mode 100644 index 651355d5..00000000 --- a/crates/mergify-ci/src/scopes_detect/config.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! YAML schema for the `scopes:` block in `.mergify.yml`. -//! -//! Mirrors `mergify_cli/ci/scopes/config/scopes.py`. Loaded once -//! at the top of [`super::run`] from whichever Mergify config the -//! user pointed at (explicit flag, env var, or auto-detected). - -use mergify_core::CliError; -use serde::Deserialize; - -/// Top-level Mergify config: we only care about the `scopes:` -/// block — every other section (queue rules, pull-request rules, -/// …) is forwarded to the engine and irrelevant here. -#[derive(Debug, Default, Deserialize)] -pub struct MergifyConfig { - #[serde(default)] - pub scopes: Scopes, -} - -/// The `scopes:` block. Both fields have explicit defaults so a -/// minimal `scopes: {}` (or no `scopes:` at all) deserializes to -/// "no sources configured" + the default merge-queue scope name. -#[derive(Debug, Deserialize)] -pub struct Scopes { - /// Where scopes come from. `None` disables source-based - /// detection entirely (the command then only ever reports - /// the merge-queue scope, if applicable). - #[serde(default)] - pub source: Option, - - /// Scope name automatically applied to merge-queue PRs. - /// Defaults to `"merge-queue"`; matches Python. The explicit - /// `Default` impl below keeps the constant in one place — - /// `#[derive(Default)]` would silently produce `String::new()` - /// when the whole `scopes:` block is missing from the YAML. - #[serde(default = "default_merge_queue_scope")] - pub merge_queue_scope: String, -} - -impl Default for Scopes { - fn default() -> Self { - Self { - source: None, - merge_queue_scope: default_merge_queue_scope(), - } - } -} - -fn default_merge_queue_scope() -> String { - "merge-queue".to_string() -} - -/// `source:` is a one-of: either a file-pattern map or the -/// `manual: null` sentinel that means "scopes are sent via -/// `scopes-send` or the API directly". -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum Source { - Files(SourceFiles), - Manual(SourceManual), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SourceFiles { - /// Scope-name → file filters. `BTreeMap` gives us sorted-by-key - /// iteration for free — Python sorts scope names before - /// printing anyway, so this both matches behavior and keeps the - /// human output deterministic without pulling in `indexmap`. - pub files: std::collections::BTreeMap, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SourceManual { - /// Sentinel field — the YAML literal is `manual: null`. Stored - /// as `Option<()>` so the field is optional and explicit-null - /// both parse. - #[allow(dead_code)] - pub manual: Option<()>, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FileFilters { - /// Glob patterns of files to include. Empty means "include - /// everything before exclusions" (matches Python's default - /// of `("**/*",)` — we keep the same default here). - #[serde(default = "default_include")] - pub include: Vec, - - /// Glob patterns of files to exclude. Applied after `include` - /// and takes precedence. - #[serde(default)] - pub exclude: Vec, -} - -fn default_include() -> Vec { - vec!["**/*".to_string()] -} - -/// Parse a Mergify config file from `path`, surfacing parse -/// errors as [`CliError::Configuration`] so the binary exits with -/// the right code (8) and an obvious "your YAML is broken" -/// message. -pub fn load(path: &std::path::Path) -> Result { - let text = std::fs::read_to_string(path) - .map_err(|e| CliError::Configuration(format!("cannot read {}: {e}", path.display())))?; - serde_yaml_ng::from_str(&text) - .map_err(|e| CliError::Configuration(format!("invalid YAML in {}: {e}", path.display()))) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse(yaml: &str) -> MergifyConfig { - serde_yaml_ng::from_str(yaml).expect("yaml parses") - } - - #[test] - fn empty_yaml_yields_defaults() { - // No `scopes:` block at all — the deserializer must still - // produce a usable `MergifyConfig` with `source: None` and - // the default merge-queue scope name. - let cfg: MergifyConfig = parse("{}"); - assert!(cfg.scopes.source.is_none()); - assert_eq!(cfg.scopes.merge_queue_scope, "merge-queue"); - } - - #[test] - fn source_files_round_trip() { - let cfg = parse( - r" -scopes: - source: - files: - backend: - include: ['mergify_cli/**'] - exclude: ['mergify_cli/tests/**'] - frontend: - include: ['web/**'] -", - ); - let Some(Source::Files(files)) = cfg.scopes.source else { - panic!("expected files source"); - }; - assert_eq!(files.files.len(), 2); - assert_eq!( - files.files["backend"].include, - vec!["mergify_cli/**".to_string()], - ); - assert_eq!( - files.files["backend"].exclude, - vec!["mergify_cli/tests/**".to_string()], - ); - assert_eq!(files.files["frontend"].include, vec!["web/**".to_string()]); - assert!(files.files["frontend"].exclude.is_empty()); - } - - #[test] - fn source_files_defaults_include_to_everything() { - // Mirror Python's `default_factory=lambda: ("**/*",)` — - // omitting `include` means "include everything before - // exclusions". - let cfg = parse( - r" -scopes: - source: - files: - everything: - exclude: ['*.md'] -", - ); - let Some(Source::Files(files)) = cfg.scopes.source else { - panic!("expected files source"); - }; - assert_eq!(files.files["everything"].include, vec!["**/*".to_string()]); - } - - #[test] - fn source_manual_round_trip() { - let cfg = parse( - r" -scopes: - source: - manual: null -", - ); - assert!(matches!(cfg.scopes.source, Some(Source::Manual(_)))); - } - - #[test] - fn merge_queue_scope_override() { - let cfg = parse( - r" -scopes: - merge_queue_scope: mq-bypass -", - ); - assert_eq!(cfg.scopes.merge_queue_scope, "mq-bypass"); - } - - #[test] - fn unknown_field_in_files_block_rejected() { - // `deny_unknown_fields` on `SourceFiles` catches typos - // like `file:` instead of `files:` — the parse must fail, - // not silently fall back to an empty map. - let err = serde_yaml_ng::from_str::( - r" -scopes: - source: - file: - backend: {} -", - ) - .unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("did not match any variant") || msg.contains("unknown field"), - "unexpected error message: {msg}" - ); - } -} diff --git a/crates/mergify-ci/src/scopes_detect/engine_glob.rs b/crates/mergify-ci/src/scopes_detect/engine_glob.rs deleted file mode 100644 index 1c7f92e0..00000000 --- a/crates/mergify-ci/src/scopes_detect/engine_glob.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! A port of the engine's scope-glob compiler. -//! -//! The engine compiles a scope glob in `mergify_engine/rules/globs.py`: -//! its own brace expansion, then Python's `glob.translate(recursive=True, -//! include_hidden=True, seps=["/", "\\"])` on every expansion, matched -//! from the start with the `regex` module and end-anchored with `\z`. -//! This module is that pipeline, step for step, emitting the regex for -//! Rust's `regex` crate instead of Python's. Each function names the -//! Python one it ports; keep them in step, and regenerate -//! `engine_glob_cases.json` against the engine when either side moves. -//! -//! What falls out of following the engine rather than a glob library: -//! -//! - `/` and `\` are both separators, in the pattern and in the path. -//! `\` escapes nothing but `{`, `}` and `,`. -//! - The pattern is split on separators before anything else, so a -//! `[...]` class never spans one: `x[a/b]y` is the literal `x[a`, -//! a separator, then the literal `b]y`. -//! - `[!x]` negates, `[^x]` is a class holding `^` and `x`, and an -//! unterminated `[` is a literal. -//! - `*` and `?` stay inside a segment; `**` crosses segments only when -//! it is a whole segment, and a lone `*` segment is never empty. -//! - `?` and classes match a character, not a byte. -//! -//! One thing is refused rather than ported. The `regex` module reads -//! `[:name:]` inside a set as a POSIX class, where `glob.translate` -//! meant literal characters. A glob class ending that way swallows its -//! own closing bracket, and the engine either rejects the pattern (an -//! unknown name) or matches something no documentation describes -//! (`[a[:alpha:]]/b` matches one character of a set holding every -//! letter, `]`, `[`, `/` and `\`, then `b`). Such a pattern is a -//! configuration error here, which is the useful answer for whoever -//! wrote it. The `cli` rows of `engine_glob_cases.json` pin it. - -use regex::Regex; -use regex::RegexBuilder; - -const SEPARATORS: [char; 2] = ['/', '\\']; - -/// What `\` escapes in brace expansion. Nothing else: `\` is also a -/// separator, and `src\**\*.py` has to keep meaning what it means. -const ESCAPABLE: [char; 3] = ['{', '}', ',']; - -/// The engine's `MAX_BRACE_EXPANSIONS`. -const MAX_BRACE_EXPANSIONS: usize = 128; - -/// The engine's `MAX_BRACE_NESTING`. -const MAX_BRACE_NESTING: usize = 32; - -const ANY_SEP: &str = r"[/\\]"; -const NOT_SEP: &str = r"[^/\\]"; - -/// Why a glob did not compile. -#[derive(Debug)] -pub enum CompileError { - /// The engine refuses the pattern too (or, for a POSIX class, reads - /// it as something else): the configuration's fault. - Invalid(String), - /// The translation did not compile: a bug in this port. - Regex(regex::Error), -} - -/// The `regex` module has no size limit and the crate's default (10 -/// MB) trips on a glob the engine accepts: 128 brace expansions of a -/// few dozen `?` each, every one a Unicode `[^/\\]` class. -const REGEX_SIZE_LIMIT: usize = 256 * 1024 * 1024; - -/// Compile `pattern` into a regex matching the paths the engine puts in -/// a scope for it. -pub fn compile(pattern: &str) -> Result { - let chars: Vec = pattern.chars().collect(); - let mut translated = Vec::new(); - for expansion in expand_braces(&chars).map_err(CompileError::Invalid)? { - translated.push(translate(&expansion).map_err(CompileError::Invalid)?); - } - // `glob.translate` wraps each expansion in `(?s:...)\z` and the - // engine joins them with `|` and matches from the start, so this - // is the same language. No translation holds a top-level `|`. - RegexBuilder::new(&format!(r"\A(?s:{})\z", translated.join("|"))) - .size_limit(REGEX_SIZE_LIMIT) - .build() - .map_err(CompileError::Regex) -} - -/// `globs.expand_braces`. -fn expand_braces(pattern: &[char]) -> Result, String> { - // Outside a group the expansion only stops at the end of the - // pattern, so the returned index carries nothing. - let (expansions, _) = expand_from(pattern, 0, 0, false)?; - Ok(expansions) -} - -/// `globs._expand_from`. -fn expand_from( - pattern: &[char], - mut index: usize, - depth: usize, - inside_group: bool, -) -> Result<(Vec, usize), String> { - let mut expansions = vec![String::new()]; - let mut literal = String::new(); - - while let Some(&c) = pattern.get(index) { - if c == '\\' - && let Some(&next) = pattern.get(index + 1) - && ESCAPABLE.contains(&next) - { - literal.push(next); - index += 2; - continue; - } - match c { - '[' => { - if let Some(end) = scan_character_class(pattern, index) { - literal.extend(&pattern[index..end]); - index = end; - continue; - } - } - '{' => { - if depth >= MAX_BRACE_NESTING { - return Err(format!( - "nests brace groups more than {MAX_BRACE_NESTING} deep" - )); - } - let (branches, next) = expand_group(pattern, index + 1, depth + 1)?; - expansions = combine(&expansions, &literal, &branches)?; - literal.clear(); - index = next; - continue; - } - '}' | ',' if inside_group => break, - '}' => return Err("unbalanced `}`: use `\\}` to match a literal brace".into()), - _ => {} - } - literal.push(c); - index += 1; - } - - Ok((combine(&expansions, &literal, &[String::new()])?, index)) -} - -/// `globs._expand_group`: `index` is just past the `{`. -fn expand_group( - pattern: &[char], - mut index: usize, - depth: usize, -) -> Result<(Vec, usize), String> { - let mut branches = Vec::new(); - loop { - let (branch, next) = expand_from(pattern, index, depth, true)?; - branches.extend(branch); - if branches.len() > MAX_BRACE_EXPANSIONS { - return Err(too_many_expansions()); - } - match pattern.get(next) { - None => return Err("unclosed `{`: use `\\{` to match a literal brace".into()), - Some('}') => { - // `_drop_empty_branches`: `foo{,.txt}` matches - // `foo.txt` but not `foo`, while `{}` and `{,}` keep - // one empty branch. - branches.retain(|b: &String| !b.is_empty()); - if branches.is_empty() { - branches.push(String::new()); - } - return Ok((branches, next + 1)); - } - // The `,` that ends this branch. - Some(_) => index = next + 1, - } - } -} - -/// `globs._scan_character_class`: the index just past the `[...]` -/// starting at `start`, or `None` when that `[` is a literal. -fn scan_character_class(pattern: &[char], start: usize) -> Option { - let mut index = start + 1; - if pattern.get(index) == Some(&'!') { - index += 1; - } - if pattern.get(index) == Some(&']') { - index += 1; - } - while let Some(&c) = pattern.get(index) { - if c == ']' { - return Some(index + 1); - } - if SEPARATORS.contains(&c) { - return None; - } - index += 1; - } - None -} - -/// `globs._combine`. -fn combine(prefixes: &[String], literal: &str, suffixes: &[String]) -> Result, String> { - if prefixes.len() * suffixes.len() > MAX_BRACE_EXPANSIONS { - return Err(too_many_expansions()); - } - Ok(prefixes - .iter() - .flat_map(|prefix| { - suffixes - .iter() - .map(move |suffix| format!("{prefix}{literal}{suffix}")) - }) - .collect()) -} - -fn too_many_expansions() -> String { - format!("expands to more than {MAX_BRACE_EXPANSIONS} alternatives") -} - -/// `glob.translate(pattern, recursive=True, include_hidden=True, -/// seps=["/", "\\"])`, without the `(?s:...)\z` wrapper. -fn translate(pattern: &str) -> Result { - let parts: Vec<&str> = pattern.split(SEPARATORS).collect(); - let last = parts.len() - 1; - let mut out = String::new(); - for (index, &part) in parts.iter().enumerate() { - match part { - "*" if index < last => { - out.push_str(NOT_SEP); - out.push('+'); - out.push_str(ANY_SEP); - } - "*" => { - out.push_str(NOT_SEP); - out.push('+'); - } - "**" if index < last => { - // Consecutive `**` segments collapse into the last one. - if parts[index + 1] != "**" { - out.push_str("(?:.+"); - out.push_str(ANY_SEP); - out.push_str(")?"); - } - } - "**" => out.push_str(".*"), - _ => { - translate_segment(part, &mut out)?; - if index < last { - out.push_str(ANY_SEP); - } - } - } - } - Ok(out) -} - -/// `fnmatch._translate(segment, star="[^/\\]*", question_mark="[^/\\]")`. -fn translate_segment(segment: &str, out: &mut String) -> Result<(), String> { - let pattern: Vec = segment.chars().collect(); - let n = pattern.len(); - let mut i = 0; - while i < n { - let c = pattern[i]; - i += 1; - match c { - '*' => { - out.push_str(NOT_SEP); - out.push('*'); - while i < n && pattern[i] == '*' { - i += 1; - } - } - '?' => out.push_str(NOT_SEP), - '[' => { - let mut j = i; - if j < n && pattern[j] == '!' { - j += 1; - } - if j < n && pattern[j] == ']' { - j += 1; - } - while j < n && pattern[j] != ']' { - j += 1; - } - if j >= n { - push_literal(out, '['); - } else { - push_class(out, &class_chunks(&pattern[i..j]))?; - i = j + 1; - } - } - c => push_literal(out, c), - } - } - Ok(()) -} - -/// The class body `fnmatch._translate` builds from `stuff`, the -/// characters between `[` and `]`, as chunks of literal characters -/// where each boundary between two chunks is a range operator: the -/// last character of one chunk to the first of the next. -fn class_chunks(stuff: &[char]) -> Vec> { - if !stuff.contains(&'-') { - return vec![stuff.to_vec()]; - } - let find_hyphen = |from: usize| (from..stuff.len()).find(|&k| stuff[k] == '-'); - let mut chunks: Vec> = Vec::new(); - let mut i = 0; - let mut k = if stuff[0] == '!' { 2 } else { 1 }; - while let Some(hyphen) = find_hyphen(k) { - chunks.push(stuff[i..hyphen].to_vec()); - i = hyphen + 1; - k = hyphen + 3; - } - let chunk = &stuff[i..]; - if !chunk.is_empty() { - chunks.push(chunk.to_vec()); - } else if let Some(last) = chunks.last_mut() { - last.push('-'); - } - // "Remove empty ranges -- invalid in RE.": a reversed range is - // dropped along with its two bounds. - for k in (1..chunks.len()).rev() { - if let (Some(&low), Some(&high)) = (chunks[k - 1].last(), chunks[k].first()) - && low > high - { - let tail = chunks.remove(k); - chunks[k - 1].pop(); - chunks[k - 1].extend(&tail[1..]); - } - } - chunks -} - -fn push_class(out: &mut String, chunks: &[Vec]) -> Result<(), String> { - let body: Vec = chunks.join(&'-'); - match body.as_slice() { - // An empty range never matches. - [] => { - out.push_str("[a&&b]"); - return Ok(()); - } - // A negated empty range matches any character, `/` included. - ['!'] => { - out.push('.'); - return Ok(()); - } - _ => {} - } - let negated = body[0] == '!'; - reject_posix_class(chunks, negated)?; - out.push('['); - if negated { - out.push('^'); - } - let mut first = true; - for (index, chunk) in chunks.iter().enumerate() { - if index > 0 { - out.push('-'); - } - for &c in chunk { - if first && negated { - // The `!` itself. - first = false; - continue; - } - first = false; - push_literal(out, c); - } - } - out.push(']'); - Ok(()) -} - -/// Refuse a class in which the `regex` module would read a POSIX -/// class (see the module doc). -fn reject_posix_class(chunks: &[Vec], negated: bool) -> Result<(), String> { - let body = python_class_body(chunks, negated); - let mut index = 0; - while index < body.len() { - match body[index] { - // An escape is one item, whatever it escapes. - '\\' => index += 2, - '[' if body.get(index + 1) == Some(&':') && is_posix_class(&body[index + 2..]) => { - return Err( - "`[:` inside a character class is read by the engine as a POSIX class, \ - not as literal characters" - .into(), - ); - } - _ => index += 1, - } - } - Ok(()) -} - -/// The set body `fnmatch._translate` emits for the class, between the -/// `[` and the `]`, escapes included: what the `regex` module parses. -fn python_class_body(chunks: &[Vec], negated: bool) -> Vec { - let mut body = Vec::new(); - for (index, chunk) in chunks.iter().enumerate() { - if index > 0 { - body.push('-'); - } - for &c in chunk { - // A segment holds no `\\`, so these are all the escapes: - // a literal `-` and the set operators. - if matches!(c, '-' | '&' | '~' | '|') { - body.push('\\'); - } - body.push(c); - } - } - if negated { - body[0] = '^'; - } else if matches!(body.first(), Some('^' | '[')) { - body.insert(0, '\\'); - } - body -} - -/// Whether `rest`, the set body after a `[:` up to the set's closing -/// `]`, parses as a POSIX class name: `regex`'s `parse_posix_class` -/// and `parse_property_name`. The name must run to the end of the body, -/// since the only `]` that can close it is the set's own. -fn is_posix_class(rest: &[char]) -> bool { - let name_part = |c: char| c.is_ascii_alphanumeric() || " &_-.".contains(c); - let value_part = |c: char| c.is_ascii_alphanumeric() || " &_-./".contains(c); - let mut index = usize::from(rest.first() == Some(&'^')); - while index < rest.len() && name_part(rest[index]) { - index += 1; - } - if matches!(rest.get(index), Some(':' | '=')) { - let start = index + 1; - let mut end = start; - while end < rest.len() && value_part(rest[end]) { - end += 1; - } - // A qualified name (`[:script=latin:]`) only when the value - // is not blank; otherwise the `:` or `=` is not consumed. - if rest[start..end].iter().any(|&c| c != ' ') { - index = end; - } - } - rest.get(index) == Some(&':') && index + 1 == rest.len() -} - -fn push_literal(out: &mut String, c: char) { - let mut buf = [0; 4]; - out.push_str(®ex::escape(c.encode_utf8(&mut buf))); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(serde::Deserialize)] - struct Case { - pattern: String, - path: String, - engine: String, - /// Set where this module answers differently on purpose: only - /// `invalid`, for the POSIX classes the module doc describes. - cli: Option, - } - - #[test] - fn every_case_gets_the_engines_verdict() { - let cases: Vec = serde_json::from_str(include_str!("engine_glob_cases.json")) - .expect("engine_glob_cases.json parses"); - let failures: Vec = cases - .iter() - .filter_map(|case| { - let got = match compile(&case.pattern) { - Err(CompileError::Invalid(_)) => "invalid", - Err(CompileError::Regex(e)) => { - panic!("{:?} does not compile: {e}", case.pattern) - } - Ok(regex) if regex.is_match(&case.path) => "match", - Ok(_) => "miss", - }; - let want = case.cli.as_deref().unwrap_or(&case.engine); - (got != want).then(|| { - format!( - "{:?} vs {:?}: got {got}, want {want}", - case.pattern, case.path - ) - }) - }) - .collect(); - assert!( - failures.is_empty(), - "{} of {} cases differ:\n{}", - failures.len(), - cases.len(), - failures.join("\n") - ); - } -} diff --git a/crates/mergify-ci/src/scopes_detect/engine_glob_cases.json b/crates/mergify-ci/src/scopes_detect/engine_glob_cases.json deleted file mode 100644 index 71017264..00000000 --- a/crates/mergify-ci/src/scopes_detect/engine_glob_cases.json +++ /dev/null @@ -1,96 +0,0 @@ -[ - {"pattern": "src/*.py", "path": "src/main.py", "engine": "match"}, - {"pattern": "src/*.py", "path": "src/deep/nested.py", "engine": "miss"}, - {"pattern": "*.md", "path": "readme.md", "engine": "match"}, - {"pattern": "*.md", "path": "docs/readme.md", "engine": "miss"}, - {"pattern": ".github/workflows/*", "path": ".github/workflows/ci.yml", "engine": "match"}, - {"pattern": ".github/workflows/*", "path": ".github/workflows/nested/ci.yml", "engine": "miss"}, - {"pattern": "package*.json", "path": "package-lock.json", "engine": "match"}, - {"pattern": "package*.json", "path": "packages/ui/tsconfig.json", "engine": "miss"}, - {"pattern": "a?c", "path": "abc", "engine": "match"}, - {"pattern": "a?c", "path": "a/c", "engine": "miss"}, - {"pattern": "**/*.py", "path": "top.py", "engine": "match"}, - {"pattern": "**/*.py", "path": "a/b/c.py", "engine": "match"}, - {"pattern": "**/x", "path": "x", "engine": "match"}, - {"pattern": "**/*", "path": "top.py", "engine": "match"}, - {"pattern": "**/*", "path": "a/b/c/deep.py", "engine": "match"}, - {"pattern": "**/tests/**", "path": "a/b/tests/c/d.py", "engine": "match"}, - {"pattern": "src/**", "path": "src/a/b.py", "engine": "match"}, - {"pattern": "src/**", "path": "src", "engine": "miss"}, - {"pattern": "src/**/*.py", "path": "src/a.py", "engine": "match"}, - {"pattern": "src/**/*.py", "path": "src/a/b.py", "engine": "match"}, - {"pattern": "**/x", "path": "deep/nested/x", "engine": "match"}, - {"pattern": "**/**/x", "path": "a/x", "engine": "match"}, - {"pattern": "a**b", "path": "axxb", "engine": "match"}, - {"pattern": "a**b", "path": "ax/xb", "engine": "miss"}, - {"pattern": "*/x", "path": "/x", "engine": "miss"}, - {"pattern": "a*/x", "path": "a/x", "engine": "match"}, - {"pattern": ".github/*", "path": "../x", "engine": "miss"}, - {"pattern": ".*", "path": "...", "engine": "match"}, - {"pattern": "*.{md,rst}", "path": "readme.md", "engine": "match"}, - {"pattern": "*.{md,rst}", "path": "readme.rst", "engine": "match"}, - {"pattern": "{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}/????????????????????????????????????????????????????????????????????????????????", "path": "abbabba/????????????????????????????????????????????????????????????????????????????????", "engine": "match"}, - {"pattern": "*.{md,rst}", "path": "docs/readme.md", "engine": "miss"}, - {"pattern": "src/{a,b}**/x", "path": "src/a/x", "engine": "match"}, - {"pattern": "foo{,.txt}", "path": "foo", "engine": "miss"}, - {"pattern": "foo{,.txt}", "path": "foo.txt", "engine": "match"}, - {"pattern": "{}", "path": "x", "engine": "miss"}, - {"pattern": "a{b,{c,d}}e", "path": "ade", "engine": "match"}, - {"pattern": "\\{a,b\\}", "path": "{a,b}", "engine": "match"}, - {"pattern": "a\\,b", "path": "a,b", "engine": "match"}, - {"pattern": "[{]x", "path": "{x", "engine": "match"}, - {"pattern": "a{b", "path": "a{b", "engine": "invalid"}, - {"pattern": "a}b", "path": "a}b", "engine": "invalid"}, - {"pattern": "{a,b", "path": "a", "engine": "invalid"}, - {"pattern": "{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}{a,b}", "path": "aaaaaaaa", "engine": "invalid"}, - {"pattern": "{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{{a}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}}", "path": "a", "engine": "invalid"}, - {"pattern": "x[^a]y", "path": "x^y", "engine": "match"}, - {"pattern": "x[^a]y", "path": "xby", "engine": "miss"}, - {"pattern": "x[!a]y", "path": "xby", "engine": "match"}, - {"pattern": "x[!a]y", "path": "xay", "engine": "miss"}, - {"pattern": "x[a/b]y", "path": "x[a/b]y", "engine": "match"}, - {"pattern": "x[a/b]y", "path": "xay", "engine": "miss"}, - {"pattern": "x[a/b]y", "path": "x[a\\b]y", "engine": "match"}, - {"pattern": "a\\b", "path": "a/b", "engine": "match"}, - {"pattern": "a\\b", "path": "a\\b", "engine": "match"}, - {"pattern": "src\\**\\*.py", "path": "src/a/b.py", "engine": "match"}, - {"pattern": "a/b", "path": "a\\b", "engine": "match"}, - {"pattern": "a\\*", "path": "a/b", "engine": "match"}, - {"pattern": "[a-c]x", "path": "bx", "engine": "match"}, - {"pattern": "[a-c-e]", "path": "-", "engine": "match"}, - {"pattern": "[a-c-e]", "path": "d", "engine": "miss"}, - {"pattern": "[]a]", "path": "]", "engine": "match"}, - {"pattern": "[!]a]", "path": "b", "engine": "match"}, - {"pattern": "[a-]", "path": "-", "engine": "match"}, - {"pattern": "[-a]", "path": "-", "engine": "match"}, - {"pattern": "[z-a]", "path": "z", "engine": "miss"}, - {"pattern": "[!z-a]", "path": "/", "engine": "match"}, - {"pattern": "[!]", "path": "!", "engine": "miss"}, - {"pattern": "x[", "path": "x[", "engine": "match"}, - {"pattern": "x[]", "path": "x[]", "engine": "match"}, - {"pattern": "[a&&b]", "path": "&", "engine": "match"}, - {"pattern": "[a||b]", "path": "|", "engine": "match"}, - {"pattern": "[a~~b]", "path": "~", "engine": "match"}, - {"pattern": "caf?.txt", "path": "café.txt", "engine": "match"}, - {"pattern": "caf??.txt", "path": "café.txt", "engine": "miss"}, - {"pattern": "caf[é].txt", "path": "café.txt", "engine": "match"}, - {"pattern": "caf?.txt", "path": "caf�.txt", "engine": "match"}, - {"pattern": "caf[!é].txt", "path": "café.txt", "engine": "miss"}, - {"pattern": "caf[à-ê].txt", "path": "café.txt", "engine": "match"}, - {"pattern": "日本?.txt", "path": "日本語.txt", "engine": "match"}, - {"pattern": "café.txt", "path": "cafe.txt", "engine": "miss"}, - {"pattern": "a(b)+c$", "path": "a(b)+c$", "engine": "match"}, - {"pattern": "a.b", "path": "axb", "engine": "miss"}, - {"pattern": "#a b", "path": "#a b", "engine": "match"}, - {"pattern": "src/**", "path": "src/a\nb.txt", "engine": "match"}, - {"pattern": "a?b", "path": "a\nb", "engine": "match"}, - {"pattern": "[a[:alpha:]]", "path": "a", "engine": "invalid"}, - {"pattern": "[a[:alpha:]]/b", "path": "ab", "engine": "match", "cli": "invalid"}, - {"pattern": "[![:alpha:]]/b", "path": "1b", "engine": "match", "cli": "invalid"}, - {"pattern": "[a[:nope:]]", "path": "a", "engine": "invalid"}, - {"pattern": "[a[:,,!:]]", "path": ",]", "engine": "match"}, - {"pattern": "[[:alpha:]]", "path": "a]", "engine": "match"}, - {"pattern": "[a[:]x]", "path": "x", "engine": "miss"}, - {"pattern": "[a[=b=]]", "path": "=]", "engine": "match"}, - {"pattern": "[a[.b.]]", "path": ".]", "engine": "match"} -] diff --git a/crates/mergify-ci/src/scopes_detect/engine_glob_cases.py b/crates/mergify-ci/src/scopes_detect/engine_glob_cases.py deleted file mode 100644 index 5e26cca4..00000000 --- a/crates/mergify-ci/src/scopes_detect/engine_glob_cases.py +++ /dev/null @@ -1,71 +0,0 @@ -# /// script -# requires-python = "~=3.14.0" -# dependencies = ["regex==2026.9.10"] -# /// -"""Fill in the engine's verdict for every case in `engine_glob_cases.json`. - -The verdicts are what the engine says, not what anyone expects it to say: -this runs the engine's own `mergify_engine/rules/globs.py` on Python 3.14 -(the engine's version, which `glob.translate` comes from) and matches with -the `regex` module, the engine's judge of a pattern. Both pins follow -`engine/pyproject.toml`: bump them with it. Add a case with any -`engine` value, run this, and review the diff: - - uv run --no-project engine_glob_cases.py /engine/mergify_engine/rules/globs.py - -Only `globs.py` is loaded. Its `regexp_engine` import is stubbed with -`regex.compile`, which is the backtracking engine `regexp_engine` always -compiles and whose answer its RE2 path is proven to give. -""" - -import importlib.util -import json -import pathlib -import sys -import types - -import regex - -CASES = pathlib.Path(__file__).with_name("engine_glob_cases.json") - - -def load_globs(path: str) -> types.ModuleType: - regexp_engine = types.ModuleType("mergify_engine.rules.regexp_engine") - regexp_engine.compile_pattern = regex.compile # type: ignore[attr-defined] - rules = types.ModuleType("mergify_engine.rules") - rules.regexp_engine = regexp_engine # type: ignore[attr-defined] - sys.modules["mergify_engine"] = types.ModuleType("mergify_engine") - sys.modules["mergify_engine.rules"] = rules - sys.modules["mergify_engine.rules.regexp_engine"] = regexp_engine - spec = importlib.util.spec_from_file_location("mergify_engine.rules.globs", path) - assert spec is not None and spec.loader is not None - globs = importlib.util.module_from_spec(spec) - spec.loader.exec_module(globs) - return globs - - -def verdict(globs: types.ModuleType, pattern: str, path: str) -> str: - # `glob_patterns_to_regexes` is what a scope's `include` / `exclude` - # compiles through, and `match` is how `filter.glob_match` asks. - try: - compiled = globs.glob_patterns_to_regexes((pattern,)) - except (globs.InvalidGlobPatternError, regex.error): - return "invalid" - return "match" if any(rx.match(path) for rx in compiled) else "miss" - - -def dump(cases: list[dict[str, str]]) -> str: - lines = ",\n".join(f" {json.dumps(case, ensure_ascii=False)}" for case in cases) - return f"[\n{lines}\n]\n" - - -def main() -> None: - globs = load_globs(sys.argv[1]) - cases = json.loads(CASES.read_text(encoding="utf-8")) - for case in cases: - case["engine"] = verdict(globs, case["pattern"], case["path"]) - CASES.write_text(dump(cases), encoding="utf-8") - - -if __name__ == "__main__": - main() diff --git a/crates/mergify-ci/src/scopes_detect/matching.rs b/crates/mergify-ci/src/scopes_detect/matching.rs deleted file mode 100644 index 86706ace..00000000 --- a/crates/mergify-ci/src/scopes_detect/matching.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Glob-pattern matching: file path → matching scopes. -//! -//! The engine derives a pull request's scopes from the very same -//! `scopes:` block, and it — not this command — is what the merge -//! queue acts on. So a glob here compiles as the engine compiles it -//! (`engine_glob`, a port of `mergify_engine/rules/globs.py`, which -//! documents the one pattern shape it refuses instead), or one config -//! means two things and `ci scopes` picks CI jobs for a -//! scope set the engine never derives. `engine_glob_cases.json` is the -//! proof: patterns and paths with the engine's own verdicts, which -//! this module must reproduce. -//! -//! One intentional behavior: a pattern with an empty `include` list -//! follows the engine and matches every path (the scope's exclude -//! list then decides) — the YAML deserializer fills the default -//! `["**/*"]` for us, so this fallthrough is mostly defensive. - -use std::collections::BTreeMap; -use std::collections::BTreeSet; - -use mergify_core::CliError; -use regex::Regex; - -use super::config::FileFilters; -use super::engine_glob; - -/// Pre-built matchers for one scope. -#[derive(Debug)] -pub struct ScopeMatcher { - pub name: String, - include: Vec, - exclude: Vec, -} - -impl ScopeMatcher { - fn matches(&self, path: &str) -> bool { - // Mirrors the Python branch: if both lists are empty the - // scope is inert. With the YAML default in place, - // `include` is never actually empty here, but the guard is - // kept so a programmatic caller with `FileFilters::default - // ()` doesn't get every file classified into the scope. - if self.include.is_empty() && self.exclude.is_empty() { - return false; - } - let positive = if self.include.is_empty() { - true - } else { - self.include.iter().any(|g| g.is_match(path)) - }; - if !positive { - return false; - } - !self.exclude.iter().any(|g| g.is_match(path)) - } -} - -/// Compile every scope's include/exclude lists once up front so -/// the per-file loop below isn't doing repeated glob construction. -pub fn compile(filters: &BTreeMap) -> Result, CliError> { - filters - .iter() - .map(|(name, f)| { - Ok(ScopeMatcher { - name: name.clone(), - include: compile_list(name, &f.include)?, - exclude: compile_list(name, &f.exclude)?, - }) - }) - .collect() -} - -fn compile_list(scope: &str, patterns: &[String]) -> Result, CliError> { - patterns.iter().map(|pat| build_glob(scope, pat)).collect() -} - -fn build_glob(scope: &str, pattern: &str) -> Result { - engine_glob::compile(pattern).map_err(|e| match e { - engine_glob::CompileError::Invalid(reason) => CliError::Configuration(format!( - "invalid glob {pattern:?} under scope {scope:?}: {reason}" - )), - // Not the config's fault: the translation is ours. - engine_glob::CompileError::Regex(e) => { - CliError::wrap(format!("compile glob {pattern:?} under scope {scope:?}"), e) - } - }) -} - -/// Result of routing a set of changed files through every scope -/// matcher. `hit` is the set of scope names with at least one -/// match; `by_scope` maps each hit scope to the files that hit -/// it (used for the verbose `ACTIONS_STEP_DEBUG=true` listing). -pub struct MatchResult { - pub hit: BTreeSet, - pub by_scope: BTreeMap>, -} - -pub fn route<'a, I>(files: I, matchers: &[ScopeMatcher]) -> MatchResult -where - I: IntoIterator, -{ - let mut hit: BTreeSet = BTreeSet::new(); - let mut by_scope: BTreeMap> = BTreeMap::new(); - for file in files { - for m in matchers { - if m.matches(file) { - hit.insert(m.name.clone()); - by_scope - .entry(m.name.clone()) - .or_default() - .push(file.to_string()); - } - } - } - MatchResult { hit, by_scope } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn filters(include: &[&str], exclude: &[&str]) -> FileFilters { - FileFilters { - include: include.iter().map(|s| (*s).to_string()).collect(), - exclude: exclude.iter().map(|s| (*s).to_string()).collect(), - } - } - - fn compile_one(include: &[&str], exclude: &[&str]) -> Vec { - let mut m = BTreeMap::new(); - m.insert("s".to_string(), filters(include, exclude)); - compile(&m).expect("globs compile") - } - - #[test] - fn separator_bounded_star_in_exclude_does_not_drop_a_scope() { - // The direction that actually skipped CI. With `*` crossing - // `/`, an `exclude: ['*.md']` swallowed every markdown file - // in the tree, so `ci scopes` reported *fewer* scopes than - // the engine and the matching CI job never ran for a pull - // request the engine did consider in scope. Bounded, the - // exclude only covers root-level markdown, as documented. - let ms = compile_one(&["**/*"], &["*.md"]); - let res = route(["docs/guide.md"], &ms); - assert!( - res.hit.contains("s"), - "nested markdown must stay in scope, got {:?}", - res.hit, - ); - let res = route(["README.md"], &ms); - assert!(res.hit.is_empty(), "unexpected hit: {:?}", res.hit); - } - - #[test] - fn exclude_takes_precedence_over_include() { - // File matches include but also matches exclude — must - // NOT be assigned the scope. - let ms = compile_one(&["src/**"], &["src/vendor/**"]); - let res = route(["src/vendor/legacy.py"], &ms); - assert!(res.hit.is_empty(), "unexpected hit: {:?}", res.hit); - } - - #[test] - fn include_required_when_present() { - // A file outside `src/**` must not slip in just because - // the exclude list doesn't catch it. (Regression guard - // for the "if include is non-empty, file must match it" - // branch.) - let ms = compile_one(&["src/**"], &["**/tests/**"]); - let res = route(["docs/readme.md"], &ms); - assert!(res.hit.is_empty(), "unexpected hit: {:?}", res.hit); - } - - #[test] - fn empty_filters_match_nothing() { - // A scope with no include and no exclude is inert — same - // as Python's `if not scope_config.include and not - // scope_config.exclude: continue` branch. (FileFilters' - // default fills include with `["**/*"]` so this case is - // only reachable via direct construction.) - let ms = compile_one(&[], &[]); - let res = route(["anything.py"], &ms); - assert!(res.hit.is_empty()); - } - - #[test] - fn multiple_files_aggregate_per_scope() { - // Two files matching the same scope both land in - // `by_scope`; the scope name appears once in `hit`. - let ms = compile_one(&["src/**"], &[]); - let res = route(["src/a.py", "src/b.py"], &ms); - assert_eq!(res.hit.len(), 1); - assert_eq!( - res.by_scope.get("s").map(Vec::as_slice), - Some(["src/a.py".to_string(), "src/b.py".to_string()].as_slice()), - ); - } - - #[test] - fn invalid_glob_surfaces_configuration_error() { - // A pattern the engine refuses (an unclosed brace group) - // fails config validation rather than never matching. - let mut m = BTreeMap::new(); - m.insert("s".to_string(), filters(&["src/{a,b"], &[])); - let err = compile(&m).unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("invalid glob"), "got {err}"); - } -} diff --git a/crates/mergify-ci/src/scopes_detect/mod.rs b/crates/mergify-ci/src/scopes_detect/mod.rs deleted file mode 100644 index cd421fc8..00000000 --- a/crates/mergify-ci/src/scopes_detect/mod.rs +++ /dev/null @@ -1,660 +0,0 @@ -//! `mergify ci scopes` — detect which scopes a build's changes -//! touch, based on file-pattern rules declared in `.mergify.yml`. -//! -//! The command is locally evaluated (no Mergify API call): it -//! loads the YAML config, figures out the `(base, head)` git -//! refs, diffs them for changed files, and walks each file -//! through every scope's include/exclude globs. On a merge-queue -//! draft whose engine git note carries the batch's scopes, it -//! skips the diff and returns those instead: the queue already -//! decided them from each batched pull request, including scopes -//! a pull request's CI reported rather than matched from files. -//! The output is a list of "touched" scopes plus a handful of -//! CI-environment side effects: -//! -//! - `$GITHUB_OUTPUT` — JSON dict `{scope: "true"|"false"}` under -//! the `scopes` key, written as a multi-line heredoc. -//! - `$BUILDKITE` is `"true"` — same dict via `buildkite-agent -//! meta-data set mergify-ci.scopes`. -//! - `$GITHUB_STEP_SUMMARY` — markdown table. -//! - `$BUILDKITE` is `"true"` — markdown via `buildkite-agent -//! annotate` at both job and build scope. -//! -//! `--write ` writes a `{"scopes": [...]}` JSON file the -//! companion `ci scopes-send` command can consume — that's the -//! shape `DetectedScope` declares. - -pub mod changed_files; -pub mod config; -mod engine_glob; -pub mod matching; -pub mod outputs; - -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; - -use mergify_core::CliError; -use mergify_core::Output; -use mergify_core::env; -use serde::Serialize; - -use crate::git_refs; -use crate::git_refs::References; -use crate::git_refs::ReferencesSource; - -pub struct ScopesOptions<'a> { - /// Explicit `--config `. `None` triggers the - /// fallback chain (env var `MERGIFY_CONFIG_PATH`, then the - /// auto-detection over - /// [`mergify_config::paths::DEFAULT_CONFIG_PATHS`]). - pub config: Option<&'a Path>, - /// Optional `--base`. Combined with `--head` to take the - /// "manual" References branch. - pub base: Option<&'a str>, - /// Optional `--head`. - pub head: Option<&'a str>, - /// `--write/-w ` — write the detected scopes as JSON - /// here. Skipped when `None`. - pub write: Option<&'a Path>, -} - -/// Wire-shape consumed by `ci scopes-send --scopes-json`. -/// Same JSON layout as Python's `DetectedScope` (a single -/// `scopes` array; the order is sorted because the in-memory -/// representation is a `BTreeSet`). -#[derive(Serialize)] -struct DetectedScope { - scopes: Vec, -} - -/// Run the `ci scopes` command. -// -// `opts` is taken by value to match every other ported command's -// `run()` shape — clippy's `needless_pass_by_value` flags it -// because every field is a `Copy` reference, but flipping to -// `&ScopesOptions` here would make the dispatch table at the -// binary boundary asymmetric for no real win. -#[allow(clippy::needless_pass_by_value)] -pub fn run(opts: ScopesOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ScopesOptions { - config, - base, - head, - write, - } = opts; - let config_path = resolve_config_path(config, output)?; - let cfg = config::load(&config_path)?; - - let refs = resolve_refs(base, head, output)?; - run_on_refs(&cfg, &refs, write, output) -} - -/// Everything `run` does once the config is loaded and the refs -/// resolved; split out so tests can hand it merge-queue refs -/// without a git note to read them from. -fn run_on_refs( - cfg: &config::MergifyConfig, - refs: &References, - write: Option<&Path>, - output: &mut dyn Output, -) -> Result<(), CliError> { - emit_refs_header(refs, output)?; - - let (all_scopes, mut scopes_hit, by_scope) = match &refs.batch_scopes { - Some(batch) => { - output.status("Scopes decided by the merge queue for this batch")?; - let all: std::collections::BTreeSet = declared_scopes(&cfg.scopes) - .union(&batch.scopes) - .cloned() - .collect(); - // A barrier impacts every scope, so every scope is tested, - // not just the concrete ones its pulls named. - let hit = if batch.all_scopes { - output.status("The batch is a merge queue barrier, selecting all scopes")?; - all.clone() - } else { - batch.scopes.clone() - }; - (all, hit, std::collections::BTreeMap::new()) - } - None => detect_scopes(&cfg.scopes, refs, output)?, - }; - - // Merge-queue scope is additive: it's part of `all_scopes` - // unconditionally and gets added to `scopes_hit` only when the - // refs came from MQ detection. - let mut all_scopes = all_scopes; - if !cfg.scopes.merge_queue_scope.is_empty() { - all_scopes.insert(cfg.scopes.merge_queue_scope.clone()); - if refs.source == ReferencesSource::MergeQueue { - scopes_hit.insert(cfg.scopes.merge_queue_scope.clone()); - } - } - - emit_scopes_listing(&scopes_hit, &by_scope, output)?; - - outputs::maybe_write_github_outputs(&all_scopes, &scopes_hit)?; - outputs::maybe_write_buildkite_metadata(&all_scopes, &scopes_hit)?; - outputs::maybe_write_github_step_summary(refs, &all_scopes, &scopes_hit)?; - outputs::maybe_write_buildkite_annotation(refs, &all_scopes, &scopes_hit); - - if let Some(write_path) = write { - write_detected_scopes(write_path, &scopes_hit)?; - } - - Ok(()) -} - -/// Auto-detection defers to [`mergify_config::paths`], so -/// `ci scopes` searches exactly what `mergify config validate` -/// searches — including the duplicate-configuration warning. -/// `MERGIFY_CONFIG_PATH` is honored ahead of it; an empty value -/// falls back to auto-detect, which is what the `gha-mergify-ci` -/// action relies on. -fn resolve_config_path( - explicit: Option<&Path>, - output: &mut dyn Output, -) -> Result { - if let Some(path) = explicit { - if path.is_file() { - return Ok(path.to_path_buf()); - } - return Err(CliError::Configuration(format!( - "config file '{}' does not exist", - path.display(), - ))); - } - if let Some(env_path) = env::var_non_empty("MERGIFY_CONFIG_PATH") { - let p = PathBuf::from(&env_path); - if !p.is_file() { - return Err(CliError::Configuration(format!( - "MERGIFY_CONFIG_PATH={env_path} does not point at a regular file", - ))); - } - return Ok(p); - } - mergify_config::paths::resolve_config_path(None, output) -} - -/// `(base, head)` resolution mirrors Python's branch in -/// `scopes/cli.py`: -/// -/// - At least one of `--base` / `--head` provided → "manual" -/// source, `head` defaults to `"HEAD"`. -/// - Neither provided → `git_refs::detect` with the production -/// notes reader (handles GHA / Buildkite / fallback). -fn resolve_refs( - base: Option<&str>, - head: Option<&str>, - output: &mut dyn Output, -) -> Result { - if base.is_some() || head.is_some() { - return Ok(References { - base: base.map(ToString::to_string), - head: head.unwrap_or("HEAD").to_string(), - source: ReferencesSource::Manual, - batch_scopes: None, - }); - } - git_refs::detect(output, &git_refs::real_notes_reader) -} - -/// Print the `Base: … / Head: … / Source: …` header lines via -/// the status sink (stderr in human mode, no-op in JSON mode). -/// Matches Python's `click.echo` of the same three lines. -fn emit_refs_header(refs: &References, output: &mut dyn Output) -> std::io::Result<()> { - if let Some(base) = &refs.base { - output.status(&format!("Base: {base}"))?; - } - output.status(&format!("Head: {head}", head = refs.head))?; - output.status(&format!("Source: {source}", source = refs.source.as_str())) -} - -/// The scopes `.mergify.yml` declares, which the outputs list -/// even when not hit. Only a `files` source declares any: `manual` -/// scopes exist once some CI reports them. -fn declared_scopes(scopes_cfg: &config::Scopes) -> std::collections::BTreeSet { - match &scopes_cfg.source { - Some(config::Source::Files(files)) => files.files.keys().cloned().collect(), - Some(config::Source::Manual(_)) | None => std::collections::BTreeSet::new(), - } -} - -type DetectResult = ( - std::collections::BTreeSet, - std::collections::BTreeSet, - std::collections::BTreeMap>, -); - -fn detect_scopes( - scopes_cfg: &config::Scopes, - refs: &References, - output: &mut dyn Output, -) -> Result { - use std::collections::BTreeMap; - use std::collections::BTreeSet; - - match &scopes_cfg.source { - None => Ok((BTreeSet::new(), BTreeSet::new(), BTreeMap::new())), - Some(config::Source::Manual(_)) => Err(CliError::Configuration( - "source `manual` has been set, scopes must be sent with `scopes-send` or API" - .to_string(), - )), - Some(config::Source::Files(files)) => { - let all = declared_scopes(scopes_cfg); - - // No base → "select all" branch, no git diff needed. - // Matches Python's `if references.base is None`. - let Some(base) = refs.base.as_deref() else { - output.status("No base provided, selecting all scopes")?; - return Ok((all.clone(), all, BTreeMap::new())); - }; - - let changed = changed_files::git_changed_files(None, base, &refs.head)?; - output.status("Changed files detected:")?; - for f in &changed { - output.status(&format!("- {}", display_untrusted(f)))?; - } - let matchers = matching::compile(&files.files)?; - let matching::MatchResult { hit, by_scope } = - matching::route(changed.iter().map(String::as_str), &matchers); - Ok((all, hit, by_scope)) - } - } -} - -/// Render a repo path, or a scope name a CI reported, for a log line. -/// -/// `git_changed_files` returns paths as git holds them, and a -/// filename may legally contain a newline or an ANSI escape. Echoed -/// raw into a GitHub Actions log, a name like -/// `evil\n::error::pwned.txt` starts a line at column 0 that the -/// runner parses as a workflow command and turns into a fabricated -/// annotation — `::add-mask::` and `::stop-commands::` are reachable -/// the same way. `escape_debug` keeps printable Unicode readable -/// (`café.txt` stays `café.txt`) while neutralizing the control -/// characters, which is exactly what git's own `core.quotePath` -/// output did for us before this module started passing `-z`. -fn display_untrusted(path: &str) -> String { - path.escape_debug().to_string() -} - -/// Print "Scopes touched:" + sorted scope names, with the -/// per-file detail under `ACTIONS_STEP_DEBUG=true` (matches -/// Python's behavior so existing CI verbose-logs read the same). -fn emit_scopes_listing( - hit: &std::collections::BTreeSet, - by_scope: &std::collections::BTreeMap>, - output: &mut dyn Output, -) -> Result<(), CliError> { - let actions_debug = env::var("ACTIONS_STEP_DEBUG").as_deref() == Some("true"); - if hit.is_empty() { - output.status("No scopes matched.")?; - return Ok(()); - } - - // Push the "Scopes touched:" block to stdout (so a downstream - // pipe captures it). Under ACTIONS_STEP_DEBUG each scope's - // matching files are listed (sorted) immediately under their - // scope name, interleaved on the same stream — matching - // Python's `click.echo` ordering so verbose CI logs read the - // same. - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "Scopes touched:")?; - for s in hit { - // A merge-queue batch's scopes can be names a CI reported, - // which the engine does not constrain to the config's - // scope-name pattern: same hazard as a path. - writeln!(w, "- {}", display_untrusted(s))?; - if actions_debug && let Some(files) = by_scope.get(s) { - let mut files: Vec<&String> = files.iter().collect(); - files.sort(); - for f in files { - writeln!(w, " {}", display_untrusted(f))?; - } - } - } - Ok(()) - })?; - Ok(()) -} - -fn write_detected_scopes( - path: &Path, - scopes: &std::collections::BTreeSet, -) -> Result<(), CliError> { - let payload = DetectedScope { - scopes: scopes.iter().cloned().collect(), - }; - let json = serde_json::to_string(&payload) - .map_err(|e| CliError::Generic(format!("failed to serialize scopes JSON: {e}")))?; - // A failed write is a runtime I/O failure (exit 1), not a - // configuration problem (exit 8): the config parsed fine, the - // output path is just unwritable. Keep the OS error as the cause. - std::fs::write(path, json) - .map_err(|e| CliError::wrap(format!("cannot write {}", path.display()), e))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use mergify_test_support::Captured; - - #[test] - fn display_untrusted_neutralizes_workflow_command_injection() { - // Since `git_changed_files` passes `-z`, git no longer - // escapes control characters for us, and a filename may - // legally hold a newline. Echoed raw, the runner would read - // the second line as a workflow command and annotate a - // passing job with a fabricated error. - let evil = "critical/evil\n::error::pwned.txt"; - let rendered = display_untrusted(evil); - assert!(!rendered.contains('\n'), "got: {rendered}"); - assert!(rendered.contains("\\n::error::"), "got: {rendered}"); - // An ANSI escape can't repaint the operator's terminal. - assert_eq!(display_untrusted("a\u{1b}[31mb"), "a\\u{1b}[31mb"); - // Ordinary printable Unicode stays readable — the listing - // is for humans reading a CI log. - assert_eq!( - display_untrusted("critical/caché.txt"), - "critical/caché.txt" - ); - } - - #[test] - fn resolve_config_path_errors_on_missing_explicit() { - let mut captured = Captured::human(); - let err = resolve_config_path(Some(Path::new("/no/such/file.yml")), &mut captured.output) - .unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("does not exist")); - } - - #[test] - fn resolve_config_path_treats_empty_env_var_as_unset() { - // Regression for the downstream `gha-mergify-ci` break - // (monorepo#33423): the action sets `MERGIFY_CONFIG_PATH=""` - // when no path was given, expecting auto-detect. Previously - // clap's `env = "MERGIFY_CONFIG_PATH"` attribute on - // `ci scopes --config` treated the empty env value as a - // present-but-empty `--config` flag and aborted parsing - // with "a value is required for '--config'", before this - // function ever ran. The fix dropped the clap `env` hook - // so this function owns the lookup — and the empty branch - // here must fall through to autodetect rather than report - // a malformed env var. - let mut captured = Captured::human(); - let result = env::testing::with_var("MERGIFY_CONFIG_PATH", Some(""), || { - resolve_config_path(None, &mut captured.output) - }); - // Either autodetect found a real config (cargo test runs - // from a workspace that contains `.mergify.yml`, so this is - // the expected branch here) or it didn't — but the - // env-var-specific error must not surface either way, - // since "empty" means "not set" by contract. - if let Err(err) = &result { - let msg = err.to_string(); - assert!( - !msg.contains("MERGIFY_CONFIG_PATH="), - "empty env var leaked into the error message: {msg}", - ); - } - } - - #[test] - fn resolve_config_path_errors_with_env_var_specific_message_when_set_but_invalid() { - // Counterpart to the empty-env test: when the user (or a - // wrapper script) sets `MERGIFY_CONFIG_PATH` to a real - // value that doesn't exist, the error must name the env - // var + the bogus path so the user can spot the typo - // without having to dig. - let mut captured = Captured::human(); - let err = - env::testing::with_var("MERGIFY_CONFIG_PATH", Some("/no/such/.mergify.yml"), || { - resolve_config_path(None, &mut captured.output).unwrap_err() - }); - let msg = err.to_string(); - assert!(msg.contains("MERGIFY_CONFIG_PATH="), "got: {msg}"); - assert!(msg.contains("/no/such/.mergify.yml"), "got: {msg}"); - } - - #[test] - fn write_detected_scopes_emits_sorted_json() { - let tmp = tempfile::tempdir().unwrap(); - let out = tmp.path().join("scopes.json"); - let mut set = std::collections::BTreeSet::new(); - set.insert("zebra".to_string()); - set.insert("alpha".to_string()); - write_detected_scopes(&out, &set).unwrap(); - let raw = std::fs::read_to_string(&out).unwrap(); - // BTreeSet iteration is sorted; the JSON reflects it. - assert_eq!(raw, r#"{"scopes":["alpha","zebra"]}"#); - } - - #[test] - fn write_detected_scopes_io_failure_is_a_runtime_error() { - // Writing under a non-existent directory fails with a plain - // I/O error. That's a runtime failure (exit 1), not a config - // problem (exit 8) — the config itself was fine. - let tmp = tempfile::tempdir().unwrap(); - let unwritable = tmp - .path() - .join("no") - .join("such") - .join("dir") - .join("scopes.json"); - let mut set = std::collections::BTreeSet::new(); - set.insert("backend".to_string()); - let err = write_detected_scopes(&unwritable, &set).unwrap_err(); - assert_eq!(err.exit_code(), mergify_core::ExitCode::GenericError); - assert!(err.to_string().contains("cannot write"), "got: {err}"); - } - - #[test] - fn run_selects_all_when_no_base_provided() { - // Hermetic mirror of the live smoke test - // `test_ci_scopes_select_all_when_no_base`: with - // `--head HEAD` and no `--base`, the command takes the - // "select all scopes" branch and reports every - // configured scope as touched. No git operations. - // - // The empty overlay hides `GITHUB_OUTPUT` so a GHA runner - // executing the suite doesn't see `run()` append a heredoc - // to its real step-output file (which would break the - // runner step with "Matching delimiter not found"). - let tmp = tempfile::tempdir().unwrap(); - let cfg = tmp.path().join("mergify.yml"); - std::fs::write( - &cfg, - "scopes:\n source:\n files:\n backend:\n include: ['src/**']\n frontend:\n include: ['web/**']\n", - ) - .unwrap(); - let mut cap = Captured::human(); - env::testing::with_no_vars(|| { - run( - ScopesOptions { - config: Some(&cfg), - base: None, - head: Some("HEAD"), - write: None, - }, - &mut cap.output, - ) - .unwrap(); - }); - let combined = cap.stdout() + &cap.stderr(); - for scope in ["backend", "frontend"] { - assert!(combined.contains(scope), "missing scope: {combined}"); - } - assert!(combined.contains("No base provided")); - } - - #[test] - fn run_errors_on_manual_source() { - // `source: manual` is the "scopes-send / API only" mode; - // running `ci scopes` against that config must abort - // with a clear message (matches Python's - // `ScopesError("source `manual` has been set ...")`). - let tmp = tempfile::tempdir().unwrap(); - let cfg = tmp.path().join("mergify.yml"); - std::fs::write(&cfg, "scopes:\n source:\n manual: null\n").unwrap(); - let mut cap = Captured::human(); - let err = env::testing::with_no_vars(|| { - run( - ScopesOptions { - config: Some(&cfg), - base: None, - head: Some("HEAD"), - write: None, - }, - &mut cap.output, - ) - .unwrap_err() - }); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("scopes-send"), "got {err}"); - } - - #[test] - fn run_writes_json_when_write_set() { - // `--write ` writes a `{"scopes": [...]}` JSON - // file the companion `ci scopes-send --scopes-json` - // consumes. The no-base "select all" path is the easiest - // way to populate scopes_hit without git operations. - let tmp = tempfile::tempdir().unwrap(); - let cfg = tmp.path().join("mergify.yml"); - std::fs::write( - &cfg, - "scopes:\n source:\n files:\n a:\n include: ['*']\n b:\n include: ['*']\n", - ) - .unwrap(); - let out = tmp.path().join("detected.json"); - let mut cap = Captured::human(); - env::testing::with_no_vars(|| { - run( - ScopesOptions { - config: Some(&cfg), - base: None, - head: Some("HEAD"), - write: Some(&out), - }, - &mut cap.output, - ) - .unwrap(); - }); - let raw = std::fs::read_to_string(&out).unwrap(); - // BTreeSet ordering → alphabetical scopes in the file. - // `merge-queue` is also added because the default - // `merge_queue_scope` lands in `all_scopes` regardless. - // But `scopes_hit` only includes it when the refs source - // is MergeQueue, which isn't the case here. - assert_eq!(raw, r#"{"scopes":["a","b"]}"#); - } - - fn merge_queue_refs(batch: &[&str]) -> References { - References { - base: Some("cafef00dcafef00dcafef00dcafef00dcafef00d".into()), - head: "HEAD".into(), - source: ReferencesSource::MergeQueue, - batch_scopes: Some(git_refs::BatchScopes { - scopes: batch.iter().map(|s| (*s).to_string()).collect(), - all_scopes: false, - }), - } - } - - #[test] - fn merge_queue_barrier_selects_every_scope() { - let tmp = tempfile::tempdir().unwrap(); - let cfg: config::MergifyConfig = serde_yaml_ng::from_str( - "scopes:\n source:\n files:\n a:\n include: ['*']\n c:\n include: ['*']\n", - ) - .unwrap(); - let mut refs = merge_queue_refs(&["reported"]); - refs.batch_scopes.as_mut().unwrap().all_scopes = true; - let write = tmp.path().join("detected.json"); - let mut cap = Captured::human(); - env::testing::with_no_vars(|| { - run_on_refs(&cfg, &refs, Some(&write), &mut cap.output).unwrap(); - }); - assert_eq!( - std::fs::read_to_string(&write).unwrap(), - r#"{"scopes":["a","c","merge-queue","reported"]}"#, - ); - } - - #[test] - fn merge_queue_batch_scopes_replace_the_diff_in_every_output() { - // The refs point at a base that does not exist, so a diff - // would fail the run: reaching the outputs proves none ran. - // `c` is declared but not in the batch, `reported` is in the - // batch but not declared (a scope some CI reported). - let tmp = tempfile::tempdir().unwrap(); - let cfg: config::MergifyConfig = serde_yaml_ng::from_str( - "scopes:\n source:\n files:\n a:\n include: ['*']\n c:\n include: ['*']\n", - ) - .unwrap(); - let gha = tmp.path().join("gha_output"); - let write = tmp.path().join("detected.json"); - let mut cap = Captured::human(); - env::testing::with_vars([("GITHUB_OUTPUT", Some(gha.to_str().unwrap()))], || { - run_on_refs( - &cfg, - &merge_queue_refs(&["a", "reported"]), - Some(&write), - &mut cap.output, - ) - .unwrap(); - }); - assert_eq!( - std::fs::read_to_string(&write).unwrap(), - r#"{"scopes":["a","merge-queue","reported"]}"#, - ); - let gha = std::fs::read_to_string(&gha).unwrap(); - assert!( - gha.contains( - r#"{"a": "true", "c": "false", "merge-queue": "true", "reported": "true"}"# - ), - "got: {gha}", - ); - assert!(!cap.stderr().contains("Changed files"), "{}", cap.stderr()); - } - - #[test] - fn merge_queue_batch_scopes_serve_a_manual_source() { - // `manual` has nothing to diff against, so off the merge - // queue it refuses; on a batch the queue already knows. - let tmp = tempfile::tempdir().unwrap(); - let cfg: config::MergifyConfig = - serde_yaml_ng::from_str("scopes:\n source:\n manual: null\n").unwrap(); - let write = tmp.path().join("detected.json"); - let mut cap = Captured::human(); - env::testing::with_no_vars(|| { - run_on_refs(&cfg, &merge_queue_refs(&[]), Some(&write), &mut cap.output).unwrap(); - }); - assert_eq!( - std::fs::read_to_string(&write).unwrap(), - r#"{"scopes":["merge-queue"]}"#, - ); - } - - #[test] - fn batch_scope_names_cannot_inject_a_workflow_command() { - let cfg = config::MergifyConfig::default(); - let mut cap = Captured::human(); - env::testing::with_no_vars(|| { - run_on_refs( - &cfg, - &merge_queue_refs(&["x\n::error::pwned"]), - None, - &mut cap.output, - ) - .unwrap(); - }); - let stdout = cap.stdout(); - assert!(!stdout.lines().any(|l| l.starts_with("::")), "{stdout:?}"); - assert!(stdout.contains(r"x\n::error::pwned"), "{stdout:?}"); - } -} diff --git a/crates/mergify-ci/src/scopes_detect/outputs.rs b/crates/mergify-ci/src/scopes_detect/outputs.rs deleted file mode 100644 index 82b34b95..00000000 --- a/crates/mergify-ci/src/scopes_detect/outputs.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Side-effect emitters for `ci scopes`: GHA outputs, Buildkite -//! metadata, GitHub step summary, Buildkite annotation. -//! -//! Each stays quiet when its environment knob is absent. - -use mergify_core::env; -use std::collections::BTreeSet; -use std::fmt::Write as _; -use std::fs::OpenOptions; -use std::io::Write; -use std::path::PathBuf; -use std::process::Command; -use std::process::Stdio; - -use mergify_core::CliError; - -use crate::git_refs::References; - -const GITHUB_ACTIONS_OUTPUT_NAME: &str = "scopes"; -const BUILDKITE_SCOPES_METADATA_KEY: &str = "mergify-ci.scopes"; -const BUILDKITE_ANNOTATION_CONTEXT: &str = "mergify-ci-scopes"; - -/// Build the GHA-style scopes payload: `{scope: "true"|"false"}` -/// with stable string-valued booleans. Mirrors Python's -/// "GHA outputs are strings; copying a bool through workflows -/// converts to the literal string `false|true`, so we make it a -/// string up front to avoid the user-visible mismatch." -fn scopes_dict_json(all: &BTreeSet, hit: &BTreeSet) -> String { - // Build by hand so the key order is the sorted ordering from - // the BTreeSet — `serde_json::Map` randomizes hash maps and - // a serialized object's key order would drift between runs. - let mut out = String::from("{"); - for (i, scope) in all.iter().enumerate() { - if i > 0 { - out.push_str(", "); - } - // `serde_json::to_string` on a `&str` quotes + escapes per - // JSON rules — safer than manual `\"{scope}\"` for scope - // names with control characters (the schema disallows - // them, but defense in depth costs nothing). - let key = serde_json::to_string(scope).expect("scope name serializes"); - let value = if hit.contains(scope) { "true" } else { "false" }; - let _ = write!(&mut out, "{key}: \"{value}\""); - } - out.push('}'); - out -} - -/// Append `scopes<, - hit: &BTreeSet, -) -> Result<(), CliError> { - let payload = scopes_dict_json(all, hit); - crate::github_output::append(&[(GITHUB_ACTIONS_OUTPUT_NAME, &payload)]) -} - -/// `buildkite-agent meta-data set mergify-ci.scopes ` when -/// `$BUILDKITE` is `"true"`. No-op otherwise. -pub fn maybe_write_buildkite_metadata( - all: &BTreeSet, - hit: &BTreeSet, -) -> Result<(), CliError> { - if env::var("BUILDKITE").as_deref() != Some("true") { - return Ok(()); - } - let payload = scopes_dict_json(all, hit); - let status = Command::new("buildkite-agent") - .args(["meta-data", "set", BUILDKITE_SCOPES_METADATA_KEY, &payload]) - .status() - .map_err(|e| CliError::Generic(format!("failed to spawn `buildkite-agent`: {e}")))?; - if !status.success() { - return Err(CliError::Generic(format!( - "`buildkite-agent meta-data set` exited with status {status}", - ))); - } - Ok(()) -} - -/// Render a scope name as a Markdown table cell's code span. -/// -/// A merge-queue batch's scopes can be names a CI reported, which -/// the engine only caps in length: a newline, `|` or backtick in one -/// would otherwise end the table or the span and let the name write -/// its own Markdown into the step summary and the Buildkite -/// annotation. -fn markdown_code(scope: &str) -> String { - let escaped = scope.escape_debug().to_string().replace('|', "\\|"); - // A code span only closes on a backtick run of its own delimiter's - // length, so the delimiter must be longer than any run inside. - let longest_run = escaped.split(|c| c != '`').map(str::len).max().unwrap_or(0); - if longest_run == 0 { - format!("`{escaped}`") - } else { - let fence = "`".repeat(longest_run + 1); - format!("{fence} {escaped} {fence}") - } -} - -fn build_summary_markdown( - refs: &References, - all: &BTreeSet, - hit: &BTreeSet, -) -> String { - let mut md = String::from("## Mergify CI Scope Matching Results"); - if let Some(base) = refs.base.as_deref() { - // Each ref is cut to 7 chars, git's abbreviated-SHA length. - // Count chars, not bytes: a branch name can be non-ASCII, and - // a byte slice would split a char. A ref name can also hold a - // backtick or a newline, so it is escaped like a scope name. - let base_short: String = base.chars().take(7).collect(); - let head_short: String = refs.head.chars().take(7).collect(); - let range = markdown_code(&format!("{base_short}...{head_short}")); - let source = refs.source.as_str(); - let _ = write!(&mut md, " for {range} (source: `{source}`)"); - } - md.push_str("\n\n| 🎯 Scope | ✅ Match |\n|:--|:--|\n"); - for scope in all { - let emoji = if hit.contains(scope) { "✅" } else { "❌" }; - let _ = writeln!(&mut md, "| {} | {emoji} |", markdown_code(scope)); - } - md -} - -/// Append the scope-matching markdown table to -/// `$GITHUB_STEP_SUMMARY` when the env var is set. No-op -/// otherwise. -pub fn maybe_write_github_step_summary( - refs: &References, - all: &BTreeSet, - hit: &BTreeSet, -) -> Result<(), CliError> { - let Some(path) = env::var_non_empty("GITHUB_STEP_SUMMARY") else { - return Ok(()); - }; - let md = build_summary_markdown(refs, all, hit); - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(PathBuf::from(path))?; - file.write_all(md.as_bytes())?; - Ok(()) -} - -/// `buildkite-agent annotate` with the same markdown, at both job -/// and build scope. Failures are non-fatal — warns on stderr and -/// continues, matching Python. The repeated context guarantees -/// re-runs update in place rather than duplicate. -pub fn maybe_write_buildkite_annotation( - refs: &References, - all: &BTreeSet, - hit: &BTreeSet, -) { - if env::var("BUILDKITE").as_deref() != Some("true") { - return; - } - let md = build_summary_markdown(refs, all, hit); - for (scope_label, extra_arg) in &[("job", Some("--scope=job")), ("build", None)] { - let mut cmd = Command::new("buildkite-agent"); - cmd.args([ - "annotate", - "--style", - "info", - "--context", - BUILDKITE_ANNOTATION_CONTEXT, - ]); - if let Some(arg) = extra_arg { - cmd.arg(arg); - } - let result = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); - let mut child = match result { - Ok(c) => c, - Err(e) => { - eprintln!( - "warning: failed to spawn buildkite-agent annotate ({scope_label} scope): {e}", - ); - continue; - } - }; - if let Some(mut stdin) = child.stdin.take() - && let Err(e) = stdin.write_all(md.as_bytes()) - { - eprintln!( - "warning: failed to pipe markdown to buildkite-agent annotate \ - ({scope_label} scope): {e}", - ); - // fall through to wait() so the child is reaped - } - let output = match child.wait_with_output() { - Ok(o) => o, - Err(e) => { - eprintln!( - "warning: failed to wait for buildkite-agent annotate \ - ({scope_label} scope): {e}", - ); - continue; - } - }; - if !output.status.success() { - let detail = String::from_utf8_lossy(&output.stderr); - eprintln!( - "warning: failed to write Buildkite annotation ({scope_label} scope): {}", - detail.trim(), - ); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::git_refs::ReferencesSource; - - fn refs(base: Option<&str>, head: &str, source: ReferencesSource) -> References { - References { - base: base.map(ToString::to_string), - head: head.to_string(), - source, - batch_scopes: None, - } - } - - #[test] - fn summary_table_cannot_be_broken_out_of_by_a_reported_scope_name() { - // Reported names are only length-capped by the engine. - let evil = "x` |\n## All checks passed"; - let all: BTreeSet = [evil.to_string(), "ok".to_string()].into(); - let hit = all.clone(); - let md = build_summary_markdown( - &refs(None, "HEAD", ReferencesSource::MergeQueue), - &all, - &hit, - ); - assert!(!md.lines().any(|l| l.starts_with("## All")), "{md}"); - assert!( - md.contains(r"| `` x` \|\n## All checks passed `` | ✅ |"), - "{md}" - ); - assert!(md.contains("| `ok` | ✅ |"), "{md}"); - } - - #[test] - fn scopes_dict_json_keys_sorted_alphabetically() { - let all: BTreeSet = ["zebra", "alpha", "mid"] - .iter() - .map(|s| (*s).to_string()) - .collect(); - let hit: BTreeSet = ["mid"].iter().map(|s| (*s).to_string()).collect(); - let out = scopes_dict_json(&all, &hit); - // BTreeSet sorts; the JSON must reflect that. - assert_eq!( - out, - r#"{"alpha": "false", "mid": "true", "zebra": "false"}"#, - ); - } - - #[test] - fn summary_markdown_omits_range_when_no_base() { - // `ci scopes --head HEAD` (no `--base`) takes the - // "select all" branch; the markdown should still render - // but skip the `for `……` (source: …)` suffix because - // there's no range to point at. - let r = refs(None, "HEAD", ReferencesSource::Manual); - let all: BTreeSet = ["a", "b"].iter().map(|s| (*s).to_string()).collect(); - let hit: BTreeSet = ["a"].iter().map(|s| (*s).to_string()).collect(); - let md = build_summary_markdown(&r, &all, &hit); - assert!( - md.starts_with("## Mergify CI Scope Matching Results\n\n"), - "got:\n{md}", - ); - assert!(md.contains("| `a` | ✅ |"), "got:\n{md}"); - assert!(md.contains("| `b` | ❌ |"), "got:\n{md}"); - } - - #[test] - fn summary_markdown_includes_range_when_base_present() { - let r = refs( - Some("0123456789abcdef0123456789abcdef01234567"), - "fedcba9876543210fedcba9876543210fedcba98", - ReferencesSource::GithubEventPullRequest, - ); - let all: BTreeSet = ["a"].iter().map(|s| (*s).to_string()).collect(); - let hit: BTreeSet = BTreeSet::new(); - let md = build_summary_markdown(&r, &all, &hit); - assert!( - md.contains("for `0123456...fedcba9` (source: `github_event_pull_request`)"), - "got:\n{md}", - ); - } - - #[test] - fn summary_markdown_short_ref_does_not_panic() { - // `HEAD` and `HEAD^` are shorter than 7 chars. Regression - // guard for `&base[..7]` on short refs. - let r = refs(Some("HEAD^"), "HEAD", ReferencesSource::Manual); - let all: BTreeSet = ["a"].iter().map(|s| (*s).to_string()).collect(); - let hit = BTreeSet::new(); - let md = build_summary_markdown(&r, &all, &hit); - assert!( - md.contains("for `HEAD^...HEAD` (source: `manual`)"), - "got:\n{md}" - ); - } - - #[test] - fn summary_markdown_truncates_non_ascii_ref_by_chars() { - // Byte 7 of each ref falls inside a multi-byte char, so a - // byte slice would panic. - let r = refs(Some("releasé-2026"), "featuré/x", ReferencesSource::Manual); - let all: BTreeSet = ["a"].iter().map(|s| (*s).to_string()).collect(); - let hit = BTreeSet::new(); - let md = build_summary_markdown(&r, &all, &hit); - assert!(md.contains("for `releasé...featuré`"), "got:\n{md}"); - } - - #[test] - fn summary_markdown_cannot_be_broken_out_of_by_a_ref_name() { - let r = refs(Some("a`\n## x"), "HEAD", ReferencesSource::Manual); - let all: BTreeSet = ["a"].iter().map(|s| (*s).to_string()).collect(); - let hit = BTreeSet::new(); - let md = build_summary_markdown(&r, &all, &hit); - assert!(md.contains("for `` a`\\n## x...HEAD ``"), "got:\n{md}"); - assert!(!md.contains("\n## x"), "got:\n{md}"); - } - - #[test] - fn markdown_code_delimiter_outlasts_consecutive_backticks() { - // A two-backtick delimiter would be closed by the `` run. - assert_eq!(markdown_code("a``"), "``` a`` ```"); - assert_eq!(markdown_code("a`b``c"), "``` a`b``c ```"); - } -} diff --git a/crates/mergify-ci/src/scopes_send.rs b/crates/mergify-ci/src/scopes_send.rs deleted file mode 100644 index 5fba0e37..00000000 --- a/crates/mergify-ci/src/scopes_send.rs +++ /dev/null @@ -1,789 +0,0 @@ -//! `mergify ci scopes-send` — report the scopes detected for a pull -//! request to Mergify. -//! -//! Scopes can come from three sources (combined): -//! -//! - one or more ``--scope `` flags -//! - ``--scopes-json ``: JSON with a ``{"scopes": [...]}`` -//! shape (the output of ``mergify ci scopes --write``) -//! - ``--scopes-file ``: plain text, one scope per line -//! -//! ``--file`` is the deprecated alias for ``--scopes-json`` and -//! emits a warning to stderr; it is hidden from the public help. -//! -//! ``--all`` marks the pull request as impacting every scope: the -//! request body carries ``all_scopes: true`` alongside the concrete -//! ``scopes`` list, and the merge queue treats the pull request as -//! a barrier. The flag is also honored when the ``--scopes-json`` -//! file carries ``"all_scopes": true``. -//! -//! Pull-request number and repository are explicit flags that fall -//! back to environment (``GITHUB_REPOSITORY``, ``GITHUB_EVENT_PATH`` -//! with ``.pull_request.number``). When neither source yields a -//! pull-request number the command prints a skip message and -//! returns success — matches Python's "no PR, nothing to send" -//! behavior. -//! -//! The report is addressed by the pull request's **head SHA** -//! (``--head-sha``, else the CI environment) so it says which -//! revision it was computed for: scopes computed for one head do not -//! describe another, and the number-addressed record cannot tell the -//! difference — whichever upload lands last wins (MRGFY-8884). The -//! number-addressed endpoint stays the fallback for the two cases -//! where the SHA route is not available: no head SHA could be -//! resolved, or the Mergify deployment is older than the endpoint and -//! answers 404. -//! -//! Auth + API URL resolution goes through `mergify_core::auth`, -//! which adds a `gh auth token` fallback (matches Python's -//! `utils.get_default_token`) and a `git config remote.origin.url` -//! fallback for the repository slug (matches -//! `utils.get_default_repository`). - -use std::path::Path; - -use mergify_core::ApiFlavor; -use mergify_core::CliError; -use mergify_core::HttpClient; -use mergify_core::Output; -use mergify_core::auth; -use serde::Deserialize; -use serde::Serialize; - -use crate::detector; - -pub struct ScopesSendOptions<'a> { - pub repository: Option<&'a str>, - pub pull_request: Option, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub scopes: &'a [String], - pub scopes_json: Option<&'a Path>, - pub scopes_file: Option<&'a Path>, - pub deprecated_file: Option<&'a Path>, - pub all_scopes: bool, - pub head_sha: Option<&'a str>, -} - -/// Run the `ci scopes-send` command. -pub async fn run(opts: ScopesSendOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let Some(pull_request) = resolve_pull_request(opts.pull_request)? else { - output.status("No pull request number detected, skipping scopes upload.")?; - return Ok(()); - }; - - let repository = detector::resolve_repository(opts.repository)?; - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::ApplicationKey)?; - - // Whenever the deprecated `--file` flag is supplied, surface - // the deprecation warning — even when `--scopes-json` is also - // set and ends up taking precedence. Users need to know `--file` - // will be going away regardless of whether the current - // invocation actually relies on it. - if opts.deprecated_file.is_some() { - output.status("Warning: --file is deprecated, use --scopes-json instead.")?; - } - let scopes_json_path = opts.scopes_json.or(opts.deprecated_file); - - let mut scopes: Vec = opts.scopes.to_vec(); - let mut all_scopes = opts.all_scopes; - if let Some(path) = scopes_json_path { - let dump = load_scopes_json(path)?; - scopes.extend(dump.scopes); - all_scopes = all_scopes || dump.all_scopes; - } - if let Some(path) = opts.scopes_file { - scopes.extend(read_scopes_text_file(path)?); - } - - let head_sha = resolve_head_sha(opts.head_sha); - if head_sha.is_none() { - output.status( - "No pull request head SHA detected, reporting against the pull \ - request number instead: the scopes will not say which revision \ - they were computed for.", - )?; - } - - let all_scopes_note = if all_scopes { - " (impacting all scopes)" - } else { - "" - }; - let target = match &head_sha { - Some(sha) => format!("commit {sha}"), - None => format!("pull request #{pull_request}"), - }; - output.status(&format!( - "Sending {} scope(s){all_scopes_note} for {target} to {api_url}…", - scopes.len(), - ))?; - - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let body = SendScopesRequest { - scopes: &scopes, - all_scopes, - }; - - // Both calls below discard the response: each endpoint answers an - // empty body on success, which a `::` variant would surface - // as "parse response JSON: error decoding response body". - if let Some(sha) = &head_sha { - let path = format!("/v1/repos/{repository}/commits/{sha}/scopes"); - let reported_against_the_commit = client.put_no_response_if_exists(&path, &body).await?; - if reported_against_the_commit { - return Ok(()); - } - // A Mergify older than the endpoint answers 404, and so does - // one that cannot see the repository — the message reports what - // was observed rather than diagnosing which, and the fallback - // surfaces the second case as its own error when the - // pull-request route 404s too. - output.status( - "The commit scopes endpoint answered 404, falling back to the \ - pull request one: the scopes will not say which revision they \ - were computed for.", - )?; - } - - // The fallback stays on POST rather than the pull-request route's - // own PUT: a deployment old enough to 404 the commit endpoint may - // be old enough to 404 that PUT too (it landed four months - // earlier), which would defeat the point of falling back. - let path = format!("/v1/repos/{repository}/pulls/{pull_request}/scopes"); - client.post_no_response(&path, &body).await?; - - Ok(()) -} - -/// The pull request head SHA to report the scopes against, or `None` -/// when nothing names one. -/// -/// Both sources are re-checked here rather than trusted. Detection -/// filters its own result and clap's `parse_head_sha` rejects a bad -/// `--head-sha`, but `ScopesSendOptions` is public: the value lands in -/// a request path, and `Client::join` resolves `..` segments, so this -/// is the same rule `detector::resolve_repository` applies to every -/// source of its own path segment. -/// -/// Lowercased for the same reason `tests_quarantine` normalizes an id -/// before pathing it: the engine folds case, but a row stored under a -/// spelling no reader looks for is worse than a rejected one, and only -/// one of the two spellings should ever be sent. -fn resolve_head_sha(explicit: Option<&str>) -> Option { - explicit - .map(ToString::to_string) - .or_else(detector::get_github_pull_request_head_sha) - .filter(|sha| detector::is_sha1_object_name(sha)) - .map(|sha| sha.to_lowercase()) -} - -fn resolve_pull_request(explicit: Option) -> Result, CliError> { - if let Some(n) = explicit { - return Ok(Some(n)); - } - detector::get_github_pull_request_number() -} - -#[derive(Deserialize)] -struct DetectedScopesFile { - scopes: Vec, - // Optional so today's `ci scopes --write` output (which doesn't - // emit it yet) keeps parsing; the config-driven detection that - // will produce it is tracked separately (MRGFY-7892). - #[serde(default)] - all_scopes: bool, -} - -fn load_scopes_json(path: &Path) -> Result { - let text = std::fs::read_to_string(path) - .map_err(|e| CliError::Configuration(format!("cannot read {}: {e}", path.display())))?; - serde_json::from_str(&text).map_err(|e| { - CliError::Configuration(format!( - "cannot parse scopes JSON from {}: {e}", - path.display(), - )) - }) -} - -fn read_scopes_text_file(path: &Path) -> Result, CliError> { - let text = std::fs::read_to_string(path) - .map_err(|e| CliError::Configuration(format!("cannot read {}: {e}", path.display())))?; - Ok(text - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToString::to_string) - .collect()) -} - -#[derive(Serialize)] -struct SendScopesRequest<'a> { - scopes: &'a [String], - all_scopes: bool, -} - -#[cfg(test)] -mod tests { - use std::fs; - - use mergify_core::env; - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - use crate::testing::write_github_event; - - #[test] - fn resolve_pull_request_prefers_explicit() { - env::testing::with_no_vars(|| { - assert_eq!(resolve_pull_request(Some(7)).unwrap(), Some(7)); - }); - } - - // Provider-aware detection (Buildkite/CircleCI/Jenkins/GHA) has - // unit coverage in `detector::tests`. This module keeps only the - // wrapper-level checks: explicit-flag precedence and error - // wrapping. - - #[test] - fn load_scopes_json_parses_dump_format() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("scopes.json"); - fs::write(&path, r#"{"scopes": ["backend", "frontend"]}"#).unwrap(); - let got = load_scopes_json(&path).unwrap(); - assert_eq!(got.scopes, vec!["backend", "frontend"]); - // A dump without the field means "not an all-scopes PR" — - // today's `ci scopes --write` output doesn't emit it. - assert!(!got.all_scopes); - } - - #[test] - fn load_scopes_json_parses_all_scopes_flag() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("scopes.json"); - fs::write(&path, r#"{"scopes": ["backend"], "all_scopes": true}"#).unwrap(); - let got = load_scopes_json(&path).unwrap(); - assert!(got.all_scopes); - } - - #[test] - fn read_scopes_text_file_strips_blanks_and_trims() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("scopes.txt"); - fs::write(&path, " backend \n\n frontend\n \n").unwrap(); - let got = read_scopes_text_file(&path).unwrap(); - assert_eq!(got, vec!["backend", "frontend"]); - } - - #[tokio::test] - async fn run_skips_when_no_pull_request_detected() { - let mut cap = Captured::human(); - env::testing::with_vars_async([("GITHUB_REPOSITORY", Some("owner/repo"))], async { - run( - ScopesSendOptions { - repository: None, - pull_request: None, - token: Some("test-token"), - api_url: Some("https://api.mergify.com"), - scopes: &[], - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - }) - .await; - let stderr_str = cap.stderr(); - assert!( - stderr_str.contains("skipping"), - "expected skip message, got {stderr_str:?}" - ); - } - - #[tokio::test] - async fn run_resolves_buildkite_repo_and_pull_request_from_env() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/99/scopes")) - .and(body_json( - serde_json::json!({"scopes": ["a"], "all_scopes": false}), - )) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["a".to_string()]; - - env::testing::with_vars_async( - [ - ("BUILDKITE", Some("true")), - ("BUILDKITE_REPO", Some("git@github.com:owner/repo.git")), - ("BUILDKITE_PULL_REQUEST", Some("99")), - ], - async { - run( - ScopesSendOptions { - repository: None, - pull_request: None, - token: Some("t"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - }, - ) - .await; - - // No `BUILDKITE_COMMIT`, so nothing names the head: the report - // falls back to the pull request number, and says so. - let err = cap.stderr(); - assert!( - err.contains("No pull request head SHA detected"), - "got: {err:?}" - ); - } - - #[tokio::test] - async fn run_posts_combined_scopes_from_all_sources() { - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let json_path = tmp.path().join("scopes.json"); - fs::write(&json_path, r#"{"scopes": ["fromjson"]}"#).unwrap(); - let txt_path = tmp.path().join("scopes.txt"); - fs::write(&txt_path, "fromtext\n").unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/42/scopes")) - .and(header("Authorization", "Bearer test-token")) - .and(body_json(serde_json::json!({ - "scopes": ["direct", "fromjson", "fromtext"], - "all_scopes": false, - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["direct".to_string()]; - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(42), - token: Some("test-token"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: Some(&json_path), - scopes_file: Some(&txt_path), - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn run_sends_all_scopes_true_when_flag_set() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/5/scopes")) - .and(body_json(serde_json::json!({ - "scopes": ["backend"], - "all_scopes": true, - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["backend".to_string()]; - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(5), - token: Some("t"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: true, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let err = cap.stderr(); - assert!(err.contains("impacting all scopes"), "got: {err:?}"); - } - - #[tokio::test] - async fn run_honors_all_scopes_from_scopes_json_file() { - // Forward-compat: when `ci scopes --write` starts emitting - // `all_scopes` (config-driven detection, MRGFY-7892), the - // flag must flow through without requiring `--all`. - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let json_path = tmp.path().join("scopes.json"); - fs::write(&json_path, r#"{"scopes": ["a"], "all_scopes": true}"#).unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/6/scopes")) - .and(body_json(serde_json::json!({ - "scopes": ["a"], - "all_scopes": true, - }))) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(6), - token: Some("t"), - api_url: Some(&api_url), - scopes: &[], - scopes_json: Some(&json_path), - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn run_succeeds_when_server_returns_empty_body() { - // Regression: the Mergify scopes-send endpoint returns an - // empty body on success. Earlier the Rust port tried to - // deserialize it as `serde_json::Value` and surfaced - // "parse response JSON: error decoding response body". - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/7/scopes")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(7), - token: Some("t"), - api_url: Some(&api_url), - scopes: &[], - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn run_reports_against_the_head_sha_from_the_github_event() { - let head = "feedface00000000000000000000000000000000"; - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path(format!("/v1/repos/owner/repo/commits/{head}/scopes"))) - .and(body_json( - serde_json::json!({"scopes": ["a"], "all_scopes": false}), - )) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let event_path = write_github_event( - tmp.path(), - &serde_json::json!({"pull_request": {"number": 42, "head": {"sha": head}}}), - ); - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["a".to_string()]; - - env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ("GITHUB_REPOSITORY", Some("owner/repo")), - // The merge commit, not the head — see - // `detector::get_github_pull_request_head_sha`. - ( - "GITHUB_SHA", - Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), - ), - ], - async { - run( - ScopesSendOptions { - repository: None, - pull_request: None, - token: Some("t"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - }, - ) - .await; - } - - #[tokio::test] - async fn run_prefers_the_explicit_head_sha_over_detection() { - let detected = "feedface00000000000000000000000000000000"; - // Uppercase on the way in: the path segment must still be the one - // spelling a reader looks for. - let explicit = "0123456789ABCDEF0123456789ABCDEF01234567"; - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path(format!( - "/v1/repos/owner/repo/commits/{}/scopes", - explicit.to_lowercase(), - ))) - .and(body_json(serde_json::json!({ - "scopes": ["backend"], - "all_scopes": true, - }))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let tmp = tempfile::tempdir().unwrap(); - let event_path = write_github_event( - tmp.path(), - &serde_json::json!({"pull_request": {"number": 42, "head": {"sha": detected}}}), - ); - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["backend".to_string()]; - - env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_EVENT_NAME", Some("pull_request")), - ("GITHUB_EVENT_PATH", Some(event_path.to_str().unwrap())), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ], - async { - run( - ScopesSendOptions { - repository: None, - pull_request: None, - token: Some("t"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: true, - head_sha: Some(explicit), - }, - &mut cap.output, - ) - .await - .unwrap(); - }, - ) - .await; - } - - #[tokio::test] - async fn run_falls_back_to_the_pull_request_when_the_commit_endpoint_is_unknown() { - // A Mergify older than the commit endpoint 404s the route. The - // upload has to keep working there, at the precision that - // deployment already had. - let head = "feedface00000000000000000000000000000000"; - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path(format!("/v1/repos/owner/repo/commits/{head}/scopes"))) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/42/scopes")) - .and(body_json( - serde_json::json!({"scopes": ["a"], "all_scopes": false}), - )) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let direct = vec!["a".to_string()]; - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(42), - token: Some("t"), - api_url: Some(&api_url), - scopes: &direct, - scopes_json: None, - scopes_file: None, - deprecated_file: None, - all_scopes: false, - head_sha: Some(head), - }, - &mut cap.output, - ) - .await - .unwrap(); - - let err = cap.stderr(); - assert!( - err.contains("commit scopes endpoint answered 404"), - "got: {err:?}" - ); - } - - #[tokio::test] - async fn run_warns_on_deprecated_file_flag() { - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let json_path = tmp.path().join("legacy.json"); - fs::write(&json_path, r#"{"scopes": ["x"]}"#).unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/1/scopes")) - .respond_with(ResponseTemplate::new(200)) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(1), - token: Some("t"), - api_url: Some(&api_url), - scopes: &[], - scopes_json: None, - scopes_file: None, - deprecated_file: Some(&json_path), - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let err = cap.stderr(); - assert!(err.contains("--file is deprecated"), "got: {err:?}"); - } - - #[tokio::test] - async fn run_warns_when_both_scopes_json_and_deprecated_file_provided() { - // The deprecation warning must surface even when - // `--scopes-json` is also set (and ends up taking - // precedence) — users shouldn't have to remove the modern - // flag to discover that `--file` is on its way out. - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let json_path = tmp.path().join("modern.json"); - fs::write(&json_path, r#"{"scopes": ["a"]}"#).unwrap(); - let deprecated_path = tmp.path().join("legacy.json"); - fs::write(&deprecated_path, r#"{"scopes": ["b"]}"#).unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/1/scopes")) - .and(body_json( - serde_json::json!({"scopes": ["a"], "all_scopes": false}), - )) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - - run( - ScopesSendOptions { - repository: Some("owner/repo"), - pull_request: Some(1), - token: Some("t"), - api_url: Some(&api_url), - scopes: &[], - scopes_json: Some(&json_path), - scopes_file: None, - deprecated_file: Some(&deprecated_path), - all_scopes: false, - head_sha: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let err = cap.stderr(); - assert!(err.contains("--file is deprecated"), "got: {err:?}"); - } -} diff --git a/crates/mergify-ci/src/testing.rs b/crates/mergify-ci/src/testing.rs deleted file mode 100644 index 1a496126..00000000 --- a/crates/mergify-ci/src/testing.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Test-only helpers shared across the CI-aware command modules -//! (`detector`, `scopes_send`, `tests_show`, `tests_quarantine`). - -use std::path::Path; -use std::path::PathBuf; - -/// Write `payload` as a GitHub Actions event file under `dir` and -/// return its path, ready to point `GITHUB_EVENT_PATH` at. The -/// payload-reading helpers across `detector` and `scopes_send` all -/// need one, and each spelling it out obscures which key the test is -/// actually about. -pub(crate) fn write_github_event(dir: &Path, payload: &serde_json::Value) -> PathBuf { - let path = dir.join("event.json"); - std::fs::write(&path, payload.to_string()).expect("write event payload"); - path -} - -/// A high-entropy alphanumeric string of `len` chars, seeded so -/// different callers produce different content. Used by the -/// `junit_process::split` tests to fill stack traces with data gzip -/// can't collapse — a repeating or low-entropy filler would compress -/// to almost nothing and never force a split, so the compressed-size -/// path would go untested. Alphanumeric output is XML-safe, so the -/// same helper works both for building `TestCase` values directly and -/// for embedding inside a `` body in an XML fixture. -pub(crate) fn incompressible(seed: u64, len: usize) -> String { - // xorshift64 → one alphanumeric char per step. - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; - (0..len) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - // `state % ALPHABET.len()` is always < 62, so it fits - // usize on every target and the conversion can't fail; - // `unwrap_or(0)` keeps it panic-free and ties the range to - // the alphabet (no magic number). - let idx = usize::try_from(state % ALPHABET.len() as u64).unwrap_or(0); - char::from(ALPHABET[idx]) - }) - .collect() -} diff --git a/crates/mergify-ci/src/tests_quarantine.rs b/crates/mergify-ci/src/tests_quarantine.rs deleted file mode 100644 index b2c1defa..00000000 --- a/crates/mergify-ci/src/tests_quarantine.rs +++ /dev/null @@ -1,1122 +0,0 @@ -//! `mergify tests quarantines add` / `remove` / `get` / `list` — add a -//! test to, remove it from, fetch one from, or list the CI Insights -//! quarantine through the public quarantine API. -//! -//! `add` addresses a test by its fully qualified name. `remove` -//! accepts either the quarantine id (deleted directly, as the DELETE -//! endpoint is keyed by it) or a test name. The quarantine resource -//! has the composite primary key `(repository, test_name)`, so a name -//! resolves to at most one quarantine per repository — `remove` and -//! `get` rely on this when they look the name up in the list endpoint. -//! `list` renders every record; `get` filters that same list to the -//! single match (the API exposes no single-quarantine GET). - -use std::io::{self, Write}; - -use chrono::DateTime; -use chrono::Utc; -use mergify_core::ApiFlavor; -use mergify_core::CliError; -use mergify_core::DeleteOutcome; -use mergify_core::ExitCode; -use mergify_core::HttpClient; -use mergify_core::Output; -use mergify_core::auth; -use serde::Deserialize; -use serde::Serialize; - -use crate::detector::resolve_repository; -use crate::detector::split_owner_repo; - -pub struct QuarantineOptions<'a> { - /// Explicit `--repository owner/repo`, or `None` to detect it - /// from the CI environment. - pub repository: Option<&'a str>, - pub test_name: &'a str, - pub reason: &'a str, - pub branch: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -pub struct UnquarantineOptions<'a> { - /// Explicit `--repository owner/repo`, or `None` to detect it - /// from the CI environment. - pub repository: Option<&'a str>, - /// A test name or a quarantine id. A UUID-shaped value is taken as - /// the quarantine id; anything else is treated as a test name. - pub name_or_id: &'a str, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -pub struct QuarantinedOptions<'a> { - /// Explicit `--repository owner/repo`, or `None` to detect it - /// from the CI environment. - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -pub struct GetOptions<'a> { - /// Explicit `--repository owner/repo`, or `None` to detect it - /// from the CI environment. - pub repository: Option<&'a str>, - /// A test name or a quarantine id. A UUID-shaped value is matched - /// against the quarantine id; anything else against the test name. - pub name_or_id: &'a str, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -/// Quarantine a test: POST the new quarantine and report the id the -/// server assigned it. -pub async fn quarantine( - opts: QuarantineOptions<'_>, - output: &mut dyn Output, -) -> Result { - let repository = resolve_repository(opts.repository)?; - let (owner, repo) = split_owner_repo(&repository)?; - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::User)?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - - let path = format!("/v1/ci/{owner}/repositories/{repo}/quarantines"); - let request = AddQuarantineRequest { - test_name: opts.test_name, - reason: opts.reason, - branch: opts.branch, - }; - let response: AddQuarantineResponse = client.post(&path, &request).await?; - - let result = QuarantineResult { - id: response.id, - test_name: opts.test_name.to_string(), - reason: opts.reason.to_string(), - branch: opts.branch.map(str::to_string), - }; - output.emit(&result, &mut |w| render_quarantined(w, &result))?; - Ok(ExitCode::Success) -} - -/// Unquarantine a test, addressed either by quarantine id (a -/// UUID-shaped value) or by test name. A test name is resolved to its -/// quarantine id through the list endpoint; an id is deleted directly. -pub async fn unquarantine( - opts: UnquarantineOptions<'_>, - output: &mut dyn Output, -) -> Result { - let repository = resolve_repository(opts.repository)?; - let (owner, repo) = split_owner_repo(&repository)?; - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::User)?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - - let target = resolve_target(&client, owner, repo, opts.name_or_id).await?; - - let delete_path = format!( - "/v1/ci/{owner}/repositories/{repo}/quarantines/{}", - target.id - ); - if client.delete_if_exists(&delete_path).await? == DeleteOutcome::NotFound { - // For a name we just listed, this means a concurrent removal - // raced us; for an id passed straight in, it never existed. - // Either way the quarantine is gone — report it as not found. - return Err(not_found(opts.name_or_id)); - } - - let result = UnquarantineResult { - id: target.id, - test_name: target.test_name, - }; - output.emit(&result, &mut |w| render_unquarantined(w, &result))?; - Ok(ExitCode::Success) -} - -/// List every quarantine recorded for the repository, one block per -/// record. Returns `Success` even when the quarantine is empty — an -/// empty list is a normal state, not an error (mirrors `freeze list`). -/// Auth, request, and decode failures still propagate as errors. -pub async fn quarantined( - opts: QuarantinedOptions<'_>, - output: &mut dyn Output, -) -> Result { - let quarantined_tests = fetch_quarantines(opts.repository, opts.token, opts.api_url).await?; - let payload = QuarantinedPayload { quarantined_tests }; - output.emit(&payload, &mut |w| render_quarantined_list(w, &payload))?; - Ok(ExitCode::Success) -} - -/// Fetch a single quarantine, addressed either by quarantine id (a -/// UUID-shaped value) or by test name, and render the full record. -/// Both keys are resolved client-side against the list endpoint, the -/// same source `quarantined` and `unquarantine` read — the API has no -/// single-quarantine GET. Errors when no quarantine matches. -pub async fn get(opts: GetOptions<'_>, output: &mut dyn Output) -> Result { - let quarantines = fetch_quarantines(opts.repository, opts.token, opts.api_url).await?; - - // Match by id when the argument is UUID-shaped, else by name — - // decided once rather than per row. - let by_id = looks_like_uuid(opts.name_or_id); - let test = quarantines - .into_iter() - .find(|quarantine| { - if by_id { - quarantine.id.eq_ignore_ascii_case(opts.name_or_id) - } else { - quarantine.test_name == opts.name_or_id - } - }) - .ok_or_else(|| not_found(opts.name_or_id))?; - - output.emit(&test, &mut |w| render_quarantined_one(w, &test))?; - Ok(ExitCode::Success) -} - -/// Fetch every quarantine recorded for the repository. Shared by -/// `quarantined` (renders all) and `get` (filters to one); omitting -/// `per_page` returns the full list in a single response. -async fn fetch_quarantines( - repository: Option<&str>, - token: Option<&str>, - api_url: Option<&str>, -) -> Result, CliError> { - let repository = resolve_repository(repository)?; - let (owner, repo) = split_owner_repo(&repository)?; - let api_url = auth::resolve_api_url(api_url)?; - let token = auth::resolve_mergify_token(token, &api_url, auth::Audience::User)?; - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - - let path = format!("/v1/ci/{owner}/repositories/{repo}/quarantines"); - let list: QuarantineList = client.get(&path).await?; - Ok(list.quarantined_tests) -} - -/// The quarantine targeted by `unquarantine`, with the test name -/// included only when we learned it (i.e. resolved by name). -struct UnquarantineTarget { - id: String, - test_name: Option, -} - -/// Map the user's `name_or_id` to a quarantine. A UUID-shaped value is -/// taken as the quarantine id verbatim (no lookup); anything else is a -/// test name, resolved through the list endpoint where each item -/// carries the id the DELETE endpoint needs. -async fn resolve_target( - client: &HttpClient, - owner: &str, - repo: &str, - name_or_id: &str, -) -> Result { - if looks_like_uuid(name_or_id) { - return Ok(UnquarantineTarget { - // Lowercase the canonical form so the path segment is one - // the API parses regardless of the input's casing. - id: name_or_id.to_lowercase(), - test_name: None, - }); - } - - // Omitting `per_page` returns the full list in a single response. - let list_path = format!("/v1/ci/{owner}/repositories/{repo}/quarantines"); - let list: QuarantineList = client.get(&list_path).await?; - list.quarantined_tests - .into_iter() - .find(|quarantine| quarantine.test_name == name_or_id) - .map(|found| UnquarantineTarget { - id: found.id, - test_name: Some(found.test_name), - }) - .ok_or_else(|| not_found(name_or_id)) -} - -/// The quarantine couldn't be found. Mirrors `queue show`'s "PR not in -/// the merge queue" — a resolvable-but-absent resource maps to a -/// Mergify API error (exit code 6). -fn not_found(name_or_id: &str) -> CliError { - CliError::MergifyApi(format!("'{name_or_id}' is not quarantined")) -} - -/// Whether `value` has the canonical hyphenated UUID shape -/// (`8-4-4-4-12` hex digits). Used to tell a quarantine id apart from -/// a test name without pulling in a UUID-parsing dependency; test -/// names realistically never take this shape. -fn looks_like_uuid(value: &str) -> bool { - let groups = [8, 4, 4, 4, 12]; - let mut parts = value.split('-'); - for length in groups { - match parts.next() { - Some(part) if part.len() == length && part.bytes().all(|b| b.is_ascii_hexdigit()) => {} - _ => return false, - } - } - parts.next().is_none() -} - -#[derive(Serialize)] -struct AddQuarantineRequest<'a> { - test_name: &'a str, - reason: &'a str, - // Omitted when absent so the server applies its "all branches" - // default, matching the `branch: str | None = None` payload field. - #[serde(skip_serializing_if = "Option::is_none")] - branch: Option<&'a str>, -} - -#[derive(Deserialize)] -struct AddQuarantineResponse { - id: String, -} - -/// The list endpoint's envelope. Generic over the row type so callers -/// pull only the fields they need: `unquarantine` resolves a name to an -/// id with the minimal [`QuarantineListItem`], while `quarantined` -/// reads the full [`QuarantinedTest`] for display. -#[derive(Deserialize)] -pub(crate) struct QuarantineList { - pub(crate) quarantined_tests: Vec, -} - -#[derive(Deserialize)] -struct QuarantineListItem { - id: String, - test_name: String, -} - -/// A full quarantine record as listed by `quarantined`. Every field is -/// re-serialized in `--json` mode, so the fields the human block omits -/// (e.g. `created_at`) still reach machine consumers. -#[derive(Deserialize, Serialize)] -struct QuarantinedTest { - id: String, - test_name: String, - reason: String, - // Null means the quarantine applies to every branch. - branch: Option, - created_at: DateTime, - // How the quarantine was created — `manual` by a user, `auto` by - // flaky detection. Kept as the raw string (the API models it as a - // free-form string with a `manual` default, not a closed enum) so - // `--json` stays faithful and an omitted value still deserializes. - #[serde(default = "default_source")] - source: String, - is_recovered: bool, -} - -fn default_source() -> String { - "manual".to_string() -} - -#[derive(Serialize)] -struct QuarantineResult { - id: String, - test_name: String, - reason: String, - // Always present (null when unscoped) so JSON consumers get a - // stable schema. - branch: Option, -} - -#[derive(Serialize)] -struct UnquarantineResult { - id: String, - // Present when the quarantine was addressed by name (or otherwise - // resolved); null when deleted directly by id, where the name is - // never fetched. - test_name: Option, -} - -/// The `quarantined` payload. Wraps the rows under `quarantined_tests` -/// so `--json` emits `{"quarantined_tests": [...]}`, mirroring the API -/// envelope. -#[derive(Serialize)] -struct QuarantinedPayload { - quarantined_tests: Vec, -} - -fn render_quarantined(w: &mut dyn Write, result: &QuarantineResult) -> io::Result<()> { - write!(w, "✓ Quarantined '{}'", result.test_name)?; - if let Some(branch) = &result.branch { - write!(w, " on branch '{branch}'")?; - } - writeln!(w, " (id: {}).", result.id) -} - -fn render_unquarantined(w: &mut dyn Write, result: &UnquarantineResult) -> io::Result<()> { - match &result.test_name { - Some(test_name) => writeln!(w, "✓ Unquarantined '{test_name}' (id: {}).", result.id), - None => writeln!(w, "✓ Unquarantined quarantine {}.", result.id), - } -} - -/// Render each quarantine as an indented block followed by a count, or -/// a single line when nothing is quarantined. The test name gets its -/// own line — never a table cell — so a long name is never wrapped -/// mid-name. Mirrors `tests show`'s detail layout in this crate. -fn render_quarantined_list(w: &mut dyn Write, payload: &QuarantinedPayload) -> io::Result<()> { - let tests = &payload.quarantined_tests; - if tests.is_empty() { - return writeln!(w, "No quarantined tests found."); - } - - for (index, test) in tests.iter().enumerate() { - if index > 0 { - writeln!(w)?; - } - render_quarantined_one(w, test)?; - } - - writeln!(w)?; - let n = tests.len(); - writeln!( - w, - "{n} quarantined test{plural}", - plural = if n == 1 { "" } else { "s" }, - ) -} - -/// One record: the test name as a header, then its id (the value -/// `quarantines remove` accepts) and metadata indented under it. A -/// null branch shows as `*`, the "all branches" marker `add`'s human -/// output already uses. -fn render_quarantined_one(w: &mut dyn Write, test: &QuarantinedTest) -> io::Result<()> { - writeln!(w, "{}", test.test_name)?; - writeln!(w, " id: {}", test.id)?; - writeln!(w, " branch: {}", test.branch.as_deref().unwrap_or("*"))?; - writeln!(w, " source: {}", test.source)?; - writeln!( - w, - " recovered: {}", - if test.is_recovered { "yes" } else { "no" }, - )?; - writeln!(w, " reason: {}", test.reason) -} - -#[cfg(test)] -mod tests { - use std::io; - use std::sync::Arc; - use std::sync::Mutex; - - use mergify_core::OutputMode; - use mergify_core::StdioOutput; - use mergify_core::env; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; - use wiremock::matchers::method; - use wiremock::matchers::path as path_matcher; - - use super::*; - - type SharedBytes = Arc>>; - - struct SharedWriter(SharedBytes); - - impl Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - struct Captured { - output: StdioOutput, - stdout: SharedBytes, - } - - fn captured(mode: OutputMode) -> Captured { - // These commands only `emit` (stdout); they never call - // `status`, so stderr is routed to a throwaway sink. - let stdout: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let output = StdioOutput::with_sinks(mode, SharedWriter(Arc::clone(&stdout)), io::sink()); - Captured { output, stdout } - } - - fn read(b: &SharedBytes) -> String { - String::from_utf8(b.lock().unwrap().clone()).unwrap() - } - - const QUARANTINES_PATH: &str = "/v1/ci/owner/repositories/repo/quarantines"; - - fn quarantine_options<'a>( - api_url: &'a str, - test_name: &'a str, - reason: &'a str, - branch: Option<&'a str>, - ) -> QuarantineOptions<'a> { - QuarantineOptions { - repository: Some("owner/repo"), - test_name, - reason, - branch, - token: Some("test-token"), - api_url: Some(api_url), - } - } - - fn unquarantine_options<'a>(api_url: &'a str, name_or_id: &'a str) -> UnquarantineOptions<'a> { - UnquarantineOptions { - repository: Some("owner/repo"), - name_or_id, - token: Some("test-token"), - api_url: Some(api_url), - } - } - - #[tokio::test] - async fn quarantine_posts_payload_and_emits_id_in_json() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path_matcher(QUARANTINES_PATH)) - .and(body_json(json!({ - "test_name": "test_login", - "reason": "flaky on CI", - "branch": "main", - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "quarantine-uuid-1"})), - ) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let exit = quarantine( - quarantine_options(&api_url, "test_login", "flaky on CI", Some("main")), - &mut cap.output, - ) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!( - value, - json!({ - "id": "quarantine-uuid-1", - "test_name": "test_login", - "reason": "flaky on CI", - "branch": "main", - }) - ); - } - - #[tokio::test] - async fn quarantine_without_branch_omits_it_from_payload() { - let server = MockServer::start().await; - // `body_json` is an exact match, so this asserts `branch` is - // absent (not sent as null) when no branch is given. - Mock::given(method("POST")) - .and(path_matcher(QUARANTINES_PATH)) - .and(body_json(json!({ - "test_name": "test_login", - "reason": "broken", - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "quarantine-uuid-2"})), - ) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - quarantine( - quarantine_options(&api_url, "test_login", "broken", None), - &mut cap.output, - ) - .await - .unwrap(); - - let out = read(&cap.stdout); - assert!(out.contains("✓ Quarantined 'test_login'"), "got {out:?}"); - assert!(out.contains("quarantine-uuid-2"), "got {out:?}"); - assert!( - !out.contains("on branch"), - "no branch line expected, got {out:?}" - ); - } - - #[tokio::test] - async fn quarantine_surfaces_already_quarantined_as_mergify_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with( - ResponseTemplate::new(400) - .set_body_string("Test 'test_login' is already quarantined"), - ) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let err = quarantine( - quarantine_options(&api_url, "test_login", "flaky", None), - &mut cap.output, - ) - .await - .unwrap_err(); - - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - assert!(err.to_string().contains("already quarantined")); - } - - #[tokio::test] - async fn unquarantine_resolves_id_by_name_then_deletes() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "quarantined_tests": [ - {"id": "id-other", "test_name": "test_logout"}, - {"id": "id-target", "test_name": "test_login"}, - ], - "size": 2, - "per_page": null, - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(path_matcher(format!("{QUARANTINES_PATH}/id-target"))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let exit = unquarantine( - unquarantine_options(&api_url, "test_login"), - &mut cap.output, - ) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!(value, json!({"id": "id-target", "test_name": "test_login"})); - } - - #[tokio::test] - async fn unquarantine_unknown_test_is_a_mergify_error() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "quarantined_tests": [{"id": "id-other", "test_name": "test_logout"}], - "size": 1, - "per_page": null, - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let err = unquarantine( - unquarantine_options(&api_url, "test_login"), - &mut cap.output, - ) - .await - .unwrap_err(); - - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - assert_eq!(err.exit_code(), ExitCode::MergifyApiError); - assert!(err.to_string().contains("is not quarantined"), "got {err}"); - // No DELETE is attempted, so stdout stays empty. - assert_eq!(read(&cap.stdout), ""); - } - - #[tokio::test] - async fn unquarantine_delete_racing_to_404_is_reported_as_not_quarantined() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "quarantined_tests": [{"id": "id-target", "test_name": "test_login"}], - "size": 1, - "per_page": null, - }))) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(path_matcher(format!("{QUARANTINES_PATH}/id-target"))) - .respond_with(ResponseTemplate::new(404).set_body_string("not found")) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let err = unquarantine( - unquarantine_options(&api_url, "test_login"), - &mut cap.output, - ) - .await - .unwrap_err(); - - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - assert!(err.to_string().contains("is not quarantined"), "got {err}"); - } - - #[tokio::test] - async fn quarantine_invalid_repository_format_is_a_configuration_error() { - let mut cap = captured(OutputMode::Human); - let opts = QuarantineOptions { - repository: Some("not-a-slash-pair"), - test_name: "test_login", - reason: "flaky", - branch: None, - token: Some("t"), - api_url: Some("https://example.invalid"), - }; - let err = quarantine(opts, &mut cap.output).await.unwrap_err(); - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - } - - #[tokio::test] - async fn unquarantine_invalid_repository_format_is_a_configuration_error() { - let mut cap = captured(OutputMode::Human); - let opts = UnquarantineOptions { - repository: Some("not-a-slash-pair"), - name_or_id: "test_login", - token: Some("t"), - api_url: Some("https://example.invalid"), - }; - let err = unquarantine(opts, &mut cap.output).await.unwrap_err(); - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - } - - // A canonical UUID used to exercise the "address by quarantine id" - // path. Its lowercase hyphenated form is what the DELETE path must - // carry. - const QUARANTINE_ID: &str = "12345678-1234-5678-1234-567812345678"; - - #[tokio::test] - async fn unquarantine_by_id_deletes_directly_without_listing() { - let server = MockServer::start().await; - // The list endpoint must not be hit when an id is given. - Mock::given(method("GET")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with(ResponseTemplate::new(500)) - .expect(0) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(path_matcher(format!("{QUARANTINES_PATH}/{QUARANTINE_ID}"))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let exit = unquarantine( - unquarantine_options(&api_url, QUARANTINE_ID), - &mut cap.output, - ) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - // `test_name` is null: addressing by id never fetches the name. - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!(value, json!({"id": QUARANTINE_ID, "test_name": null})); - } - - #[tokio::test] - async fn unquarantine_by_id_normalizes_uppercase_to_canonical_path() { - let server = MockServer::start().await; - // DELETE must target the lowercase canonical form even though - // the user passed the id uppercased. - Mock::given(method("DELETE")) - .and(path_matcher(format!("{QUARANTINES_PATH}/{QUARANTINE_ID}"))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let uppercased = QUARANTINE_ID.to_uppercase(); - unquarantine(unquarantine_options(&api_url, &uppercased), &mut cap.output) - .await - .unwrap(); - - let out = read(&cap.stdout); - assert!( - out.contains(&format!("quarantine {QUARANTINE_ID}")), - "got {out:?}" - ); - } - - #[tokio::test] - async fn unquarantine_by_unknown_id_404_is_a_mergify_error() { - let server = MockServer::start().await; - Mock::given(method("DELETE")) - .and(path_matcher(format!("{QUARANTINES_PATH}/{QUARANTINE_ID}"))) - .respond_with(ResponseTemplate::new(404).set_body_string("not found")) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let err = unquarantine( - unquarantine_options(&api_url, QUARANTINE_ID), - &mut cap.output, - ) - .await - .unwrap_err(); - - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - assert!(err.to_string().contains("is not quarantined"), "got {err}"); - assert_eq!(read(&cap.stdout), ""); - } - - fn quarantined_options(api_url: &str) -> QuarantinedOptions<'_> { - QuarantinedOptions { - repository: Some("owner/repo"), - token: Some("test-token"), - api_url: Some(api_url), - } - } - - async fn mount_quarantine_list(server: &MockServer, body: serde_json::Value) { - Mock::given(method("GET")) - .and(path_matcher(QUARANTINES_PATH)) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .expect(1) - .mount(server) - .await; - } - - #[tokio::test] - async fn quarantined_detects_repository_from_ci_env() { - // With no `--repository`, the command resolves the repository - // from the CI environment — here GitHub Actions' - // `GITHUB_REPOSITORY` — and queries that repository's endpoint. - env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ], - async { - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({"quarantined_tests": [], "size": 0, "per_page": null}), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let opts = QuarantinedOptions { - repository: None, - token: Some("test-token"), - api_url: Some(&api_url), - }; - let exit = quarantined(opts, &mut cap.output).await.unwrap(); - assert_eq!(exit, ExitCode::Success); - }, - ) - .await; - } - - #[tokio::test] - async fn quarantined_renders_each_record_as_a_block() { - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({ - "quarantined_tests": [ - { - "id": "id-1", - "test_name": "test_login", - "reason": "flaky — MRGFY-1234", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "source": "manual", - "is_recovered": false, - }, - { - "id": "id-2", - "test_name": "test_logout", - "reason": "consistently failing", - "branch": "release/*", - "created_at": "2026-02-01T12:00:00Z", - "source": "auto", - "is_recovered": true, - }, - ], - "size": 2, - "per_page": null, - }), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let exit = quarantined(quarantined_options(&api_url), &mut cap.output) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let out = read(&cap.stdout); - // Each test name sits on its own line (a block header), with the - // quarantine id shown so the row is actionable for `unquarantine`. - assert!(out.contains("test_login\n"), "got {out:?}"); - assert!(out.contains("id: id-1"), "id missing, got {out:?}"); - assert!(out.contains("id: id-2"), "id missing, got {out:?}"); - // A null branch renders as the all-branches marker. - assert!( - out.contains("branch: *"), - "null branch should show '*', got {out:?}" - ); - assert!(out.contains("source: manual"), "got {out:?}"); - assert!(out.contains("branch: release/*"), "got {out:?}"); - assert!(out.contains("source: auto"), "got {out:?}"); - assert!(out.contains("recovered: yes"), "got {out:?}"); - assert!(out.contains("recovered: no"), "got {out:?}"); - assert!( - out.contains("reason: flaky — MRGFY-1234"), - "got {out:?}" - ); - assert!( - out.contains("2 quarantined tests"), - "count missing, got {out:?}" - ); - } - - #[tokio::test] - async fn quarantined_empty_list_renders_message() { - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({"quarantined_tests": [], "size": 0, "per_page": null}), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - quarantined(quarantined_options(&api_url), &mut cap.output) - .await - .unwrap(); - - let out = read(&cap.stdout); - assert!(out.contains("No quarantined tests found"), "got {out:?}"); - } - - #[tokio::test] - async fn quarantined_json_emits_every_field() { - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({ - "quarantined_tests": [{ - "id": "id-1", - "test_name": "test_login", - "reason": "flaky", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "source": "auto", - "is_recovered": true, - }], - "size": 1, - "per_page": null, - }), - ) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - quarantined(quarantined_options(&api_url), &mut cap.output) - .await - .unwrap(); - - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!( - value, - json!({ - "quarantined_tests": [{ - "id": "id-1", - "test_name": "test_login", - "reason": "flaky", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "source": "auto", - "is_recovered": true, - }], - }) - ); - } - - #[tokio::test] - async fn quarantined_preserves_unmodeled_source_verbatim() { - // The API models `source` as a free-form string; a value beyond - // manual/auto must reach `--json` unchanged, not normalized. - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({ - "quarantined_tests": [{ - "id": "id-1", - "test_name": "test_login", - "reason": "flaky", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "source": "some_future_source", - "is_recovered": false, - }], - "size": 1, - "per_page": null, - }), - ) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - quarantined(quarantined_options(&api_url), &mut cap.output) - .await - .unwrap(); - - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!( - value["quarantined_tests"][0]["source"], "some_future_source", - "unmodeled source must round-trip verbatim, got {value}" - ); - } - - #[tokio::test] - async fn quarantined_omitted_source_defaults_to_manual() { - // The schema marks `source` optional with a `manual` default, so - // a record that omits it must still deserialize — not abort the - // whole list — and render as `manual`. - let server = MockServer::start().await; - mount_quarantine_list( - &server, - json!({ - "quarantined_tests": [{ - "id": "id-1", - "test_name": "test_login", - "reason": "flaky", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "is_recovered": false, - }], - "size": 1, - "per_page": null, - }), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - quarantined(quarantined_options(&api_url), &mut cap.output) - .await - .unwrap(); - - let out = read(&cap.stdout); - assert!(out.contains("source: manual"), "got {out:?}"); - } - - #[tokio::test] - async fn quarantined_invalid_repository_format_is_a_configuration_error() { - let mut cap = captured(OutputMode::Human); - let opts = QuarantinedOptions { - repository: Some("not-a-slash-pair"), - token: Some("t"), - api_url: Some("https://example.invalid"), - }; - let err = quarantined(opts, &mut cap.output).await.unwrap_err(); - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - } - - fn get_options<'a>(api_url: &'a str, name_or_id: &'a str) -> GetOptions<'a> { - GetOptions { - repository: Some("owner/repo"), - name_or_id, - token: Some("test-token"), - api_url: Some(api_url), - } - } - - fn one_quarantine_list(id: &str, test_name: &str) -> serde_json::Value { - json!({ - "quarantined_tests": [{ - "id": id, - "test_name": test_name, - "reason": "flaky — MRGFY-1234", - "branch": null, - "created_at": "2026-01-01T10:00:00Z", - "source": "manual", - "is_recovered": false, - }], - "size": 1, - "per_page": null, - }) - } - - #[tokio::test] - async fn get_by_name_renders_the_matching_record() { - let server = MockServer::start().await; - mount_quarantine_list(&server, one_quarantine_list("id-1", "test_login")).await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let exit = get(get_options(&api_url, "test_login"), &mut cap.output) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let out = read(&cap.stdout); - assert!(out.contains("test_login\n"), "got {out:?}"); - assert!(out.contains("id: id-1"), "got {out:?}"); - assert!( - out.contains("reason: flaky — MRGFY-1234"), - "got {out:?}" - ); - } - - #[tokio::test] - async fn get_by_id_emits_the_record_in_json() { - let id = "12345678-1234-5678-1234-567812345678"; - let server = MockServer::start().await; - mount_quarantine_list(&server, one_quarantine_list(id, "test_logout")).await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - // An uppercase id still matches the canonical lowercase record. - let upper_id = id.to_uppercase(); - let exit = get(get_options(&api_url, &upper_id), &mut cap.output) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let value: serde_json::Value = serde_json::from_str(&read(&cap.stdout)).unwrap(); - assert_eq!(value["id"], id); - assert_eq!(value["test_name"], "test_logout"); - } - - #[tokio::test] - async fn get_unknown_test_is_a_mergify_error() { - let server = MockServer::start().await; - mount_quarantine_list(&server, one_quarantine_list("id-1", "test_login")).await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let err = get(get_options(&api_url, "ghost"), &mut cap.output) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - } -} diff --git a/crates/mergify-ci/src/tests_show.rs b/crates/mergify-ci/src/tests_show.rs deleted file mode 100644 index ad049a49..00000000 --- a/crates/mergify-ci/src/tests_show.rs +++ /dev/null @@ -1,947 +0,0 @@ -//! `mergify tests show` — resolve test identities by name and fetch -//! full health/metrics details for each match. -//! -//! `show` is a read-only command: it always exits `0` once it has -//! rendered the matched tests, regardless of their health. Callers -//! that need to gate on health consume the structured output instead -//! (e.g. `--json | jq`). Real failures surface as [`CliError`]. - -use std::io::{self, Write}; -use std::sync::Arc; - -use chrono::DateTime; -use chrono::Utc; -use mergify_core::ApiFlavor; -use mergify_core::CliError; -use mergify_core::ExitCode; -use mergify_core::HttpClient; -use mergify_core::Output; -use mergify_core::auth; -use serde::Deserialize; -use serde::Serialize; - -use crate::detector::resolve_repository; -use crate::detector::split_owner_repo; - -const DETAILS_FANOUT: usize = 5; - -pub struct TestsShowOptions<'a> { - /// Explicit `--repository owner/repo`, or `None` to detect it - /// from the CI environment. - pub repository: Option<&'a str>, - pub test_names: &'a [String], - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub pipeline_name: &'a [String], - pub pipeline_name_exclude: &'a [String], - pub job_name: &'a [String], - pub job_name_exclude: &'a [String], - pub per_page: Option, -} - -/// Run the command, rendering every matched test. Returns -/// [`ExitCode::Success`] on success; failures surface as [`CliError`]. -pub async fn run( - opts: TestsShowOptions<'_>, - output: &mut dyn Output, -) -> Result { - let repository = resolve_repository(opts.repository)?; - let (owner, repo) = split_owner_repo(&repository)?; - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::User)?; - let client = Arc::new(HttpClient::new(api_url, token, ApiFlavor::Mergify)?); - - let search_path = format!("/v1/ci/{owner}/repositories/{repo}/search/tests"); - let identities = search(&client, &search_path, &opts).await?; - - if identities.is_empty() { - let quoted = opts - .test_names - .iter() - .map(|n| format!("'{n}'")) - .collect::>() - .join(", "); - output.status(&format!("no tests matched {quoted}"))?; - let payload = TestsShowPayload { tests: vec![] }; - output.emit(&payload, &mut |_| Ok(()))?; - return Ok(ExitCode::Success); - } - - let details = fetch_all_details(client.clone(), owner, repo, &identities).await?; - - let payload = TestsShowPayload::new(identities, details); - output.emit(&payload, &mut |w| render_human(w, &payload))?; - - Ok(ExitCode::Success) -} - -async fn search( - client: &HttpClient, - path: &str, - opts: &TestsShowOptions<'_>, -) -> Result, CliError> { - let per_page = opts.per_page.map(|n| n.to_string()); - let query = build_search_query(opts, per_page.as_deref()); - let response: SearchTestsResponse = client.get_with_query(path, &query).await?; - Ok(response.tests) -} - -/// Optional filters with no values are omitted so the server falls -/// back to its own defaults. -fn build_search_query<'a>( - opts: &'a TestsShowOptions<'a>, - per_page: Option<&'a str>, -) -> Vec<(&'a str, &'a str)> { - let mut query: Vec<(&str, &str)> = Vec::new(); - for name in opts.test_names { - query.push(("test_name", name)); - } - for name in opts.pipeline_name { - query.push(("pipeline_name", name)); - } - for name in opts.pipeline_name_exclude { - query.push(("pipeline_name_exclude", name)); - } - for name in opts.job_name { - query.push(("job_name", name)); - } - for name in opts.job_name_exclude { - query.push(("job_name_exclude", name)); - } - if let Some(per_page) = per_page { - query.push(("per_page", per_page)); - } - query -} - -/// Returned details follow the input order of `identities` regardless -/// of completion order — callers rely on it for stable output. -async fn fetch_all_details( - client: Arc, - owner: &str, - repo: &str, - identities: &[TestSearchResult], -) -> Result, CliError> { - let mut set: tokio::task::JoinSet<(usize, Result)> = - tokio::task::JoinSet::new(); - let mut results: Vec> = (0..identities.len()).map(|_| None).collect(); - let mut next = 0usize; - - let spawn_at = |set: &mut tokio::task::JoinSet<_>, index: usize| { - let client = client.clone(); - let path = format!( - "/v1/ci/{owner}/repositories/{repo}/tests/{}", - identities[index].test_id, - ); - set.spawn(async move { (index, client.get::(&path).await) }); - }; - - while next < identities.len() && next < DETAILS_FANOUT { - spawn_at(&mut set, next); - next += 1; - } - while let Some(joined) = set.join_next().await { - // Tasks are never cancelled, so a `JoinError` can only mean a - // panic — propagate it verbatim instead of wrapping. - let (index, result) = - joined.unwrap_or_else(|err| std::panic::resume_unwind(err.into_panic())); - results[index] = Some(result?); - if next < identities.len() { - spawn_at(&mut set, next); - next += 1; - } - } - - Ok(results - .into_iter() - .map(|slot| slot.expect("slot filled by JoinSet loop")) - .collect()) -} - -#[derive(Deserialize)] -struct SearchTestsResponse { - tests: Vec, -} - -#[derive(Deserialize)] -struct TestSearchResult { - test_id: String, - pipeline_name: String, - job_name: String, -} - -#[derive(Deserialize, Serialize)] -struct TestDetails { - repository: String, - test_name: String, - test_id: String, - health_status: HealthStatus, - last_conclusion: LastConclusion, - failure_ratio: f64, - flakiness_ratio: f64, - success_ratio: f64, - flaky_detection_enabled: bool, - first_failure_at: Option>, - first_failure_commit: Option, - first_failure_pull: Option, - last_failure_at: Option>, - last_success_at: Option>, - test_framework: Option, - test_framework_version: Option, - test_programming_language: Option, - test_filepath: Option, - test_function_name: Option, -} - -#[derive(Deserialize, Serialize)] -struct TestDetailsPull { - id: u64, - number: u64, - title: String, - user: TestDetailsUser, -} - -#[derive(Deserialize, Serialize)] -struct TestDetailsUser { - id: u64, - login: String, -} - -#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum HealthStatus { - Healthy, - Flaky, - Broken, - /// Captures any future server-side enum value so a single - /// unrecognized status does not abort the whole batch. - #[serde(other, alias = "unknown")] - Unknown, -} - -#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -enum LastConclusion { - Passed, - Failed, - Skipped, - /// Reserved for future API additions; rendered as `—`. - #[serde(other)] - Unknown, -} - -#[derive(Serialize)] -struct TestsShowPayload { - tests: Vec, -} - -#[derive(Serialize)] -struct TestRow { - pipeline_name: String, - job_name: String, - #[serde(flatten)] - details: TestDetails, -} - -impl TestsShowPayload { - fn new(identities: Vec, details: Vec) -> Self { - let tests = identities - .into_iter() - .zip(details) - .map(|(identity, details)| TestRow { - pipeline_name: identity.pipeline_name, - job_name: identity.job_name, - details, - }) - .collect(); - Self { tests } - } -} - -fn render_human(w: &mut dyn Write, payload: &TestsShowPayload) -> io::Result<()> { - for (index, row) in payload.tests.iter().enumerate() { - if index > 0 { - writeln!(w)?; - } - render_one(w, row)?; - } - Ok(()) -} - -fn render_one(w: &mut dyn Write, row: &TestRow) -> io::Result<()> { - let details = &row.details; - writeln!(w, "{}", details.test_name)?; - writeln!(w, " test_id: {}", details.test_id)?; - writeln!( - w, - " pipeline: {} › job: {}", - row.pipeline_name, row.job_name, - )?; - if let Some(language) = &details.test_programming_language { - writeln!(w, " language: {language}")?; - } - if let Some(framework) = &details.test_framework { - match &details.test_framework_version { - Some(version) => writeln!(w, " framework: {framework} {version}")?, - None => writeln!(w, " framework: {framework}")?, - } - } - if let Some(filepath) = &details.test_filepath { - writeln!(w, " file: {filepath}")?; - } - if let Some(function) = &details.test_function_name { - writeln!(w, " function: {function}")?; - } - writeln!( - w, - " health: {}", - health_label(details.health_status) - )?; - writeln!( - w, - " last result: {}", - conclusion_label(details.last_conclusion) - )?; - writeln!(w, " success ratio: {:.1}%", details.success_ratio * 100.0)?; - writeln!(w, " failure ratio: {:.1}%", details.failure_ratio * 100.0)?; - if details.flaky_detection_enabled { - writeln!( - w, - " flakiness: {:.1}%", - details.flakiness_ratio * 100.0 - )?; - } - if let Some(ts) = details.last_success_at { - writeln!(w, " last success: {}", format_timestamp(ts))?; - } - if let Some(ts) = details.last_failure_at { - writeln!(w, " last failure: {}", format_timestamp(ts))?; - } - if let Some(ts) = details.first_failure_at { - writeln!(w, " first failure: {}", format_timestamp(ts))?; - let commit = details.first_failure_commit.as_deref(); - if let Some(pull) = &details.first_failure_pull { - let prefix = commit - .map(|c| format!("commit {} in ", short_sha(c))) - .unwrap_or_default(); - writeln!( - w, - " {prefix}#{} \"{}\"", - pull.number, pull.title, - )?; - writeln!(w, " by {}", pull.user.login)?; - } else if let Some(short) = commit.map(short_sha) { - writeln!(w, " commit {short}")?; - } - } - Ok(()) -} - -fn health_label(status: HealthStatus) -> &'static str { - match status { - HealthStatus::Healthy => "● healthy", - HealthStatus::Flaky => "● flaky", - HealthStatus::Broken => "✗ broken", - HealthStatus::Unknown => "— unknown", - } -} - -fn conclusion_label(conclusion: LastConclusion) -> &'static str { - match conclusion { - LastConclusion::Passed => "✓ passed", - LastConclusion::Failed => "✗ failed", - LastConclusion::Skipped => "○ skipped", - LastConclusion::Unknown => "— unknown", - } -} - -fn format_timestamp(ts: DateTime) -> String { - ts.format("%Y-%m-%d %H:%M UTC").to_string() -} - -fn short_sha(sha: &str) -> String { - sha.chars().take(7).collect() -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::Mutex; - - use mergify_core::OutputMode; - use mergify_core::StdioOutput; - use mergify_core::env; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path as path_matcher; - - use super::*; - - type SharedBytes = Arc>>; - - struct SharedWriter(SharedBytes); - - impl Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - struct Captured { - output: StdioOutput, - stdout: SharedBytes, - stderr: SharedBytes, - } - - fn captured(mode: OutputMode) -> Captured { - let stdout: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let stderr: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let output = StdioOutput::with_sinks( - mode, - SharedWriter(Arc::clone(&stdout)), - SharedWriter(Arc::clone(&stderr)), - ); - Captured { - output, - stdout, - stderr, - } - } - - fn read(b: &SharedBytes) -> String { - String::from_utf8(b.lock().unwrap().clone()).unwrap() - } - - /// Mount the search endpoint. The fixture body usually contains - /// just `{"tests": [...]}`; the real API also returns `size` and - /// `per_page` next to `tests`, but the CLI ignores those, so - /// callers don't need to pass them. - async fn mount_search(server: &MockServer, body: serde_json::Value) { - Mock::given(method("GET")) - .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .mount(server) - .await; - } - - async fn mount_details(server: &MockServer, id: &str, response: ResponseTemplate) { - Mock::given(method("GET")) - .and(path_matcher(format!( - "/v1/ci/owner/repositories/repo/tests/{id}" - ))) - .respond_with(response) - .mount(server) - .await; - } - - fn details_template(id: &str, name: &str, health: &str, conclusion: &str) -> ResponseTemplate { - ResponseTemplate::new(200).set_body_json(details_json(id, name, health, conclusion)) - } - - fn details_json( - test_id: &str, - test_name: &str, - health: &str, - conclusion: &str, - ) -> serde_json::Value { - json!({ - "repository": "monorepo", - "test_name": test_name, - "test_id": test_id, - "health_status": health, - "last_conclusion": conclusion, - "failure_ratio": 0.08, - "flakiness_ratio": 0.0, - "success_ratio": 0.92, - "flaky_detection_enabled": false, - "first_failure_at": null, - "first_failure_commit": null, - "first_failure_pull": null, - "last_failure_at": null, - "last_success_at": null, - "test_framework": null, - "test_framework_version": null, - "test_programming_language": null, - "test_filepath": null, - "test_function_name": null, - }) - } - - fn test_id(n: usize) -> String { - format!("00000000-0000-5000-8000-00000000000{n}") - } - - fn options<'a>(api_url: &'a str, names: &'a [String]) -> TestsShowOptions<'a> { - TestsShowOptions { - repository: Some("owner/repo"), - test_names: names, - token: Some("test-token"), - api_url: Some(api_url), - pipeline_name: &[], - pipeline_name_exclude: &[], - job_name: &[], - job_name_exclude: &[], - per_page: None, - } - } - - #[tokio::test] - async fn empty_search_emits_empty_tests_and_returns_success() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tests": []}))) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["ghost".to_string()]; - let exit = run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let stdout = read(&cap.stdout); - assert_eq!( - serde_json::from_str::(&stdout).unwrap(), - json!({"tests": []}) - ); - } - - #[tokio::test] - async fn detects_repository_from_ci_env() { - // With no `--repository`, the command resolves the repository - // from the CI environment — here GitHub Actions' - // `GITHUB_REPOSITORY` — and queries that repository's endpoint. - env::testing::with_vars_async( - [ - ("GITHUB_ACTIONS", Some("true")), - ("GITHUB_REPOSITORY", Some("owner/repo")), - ], - async { - let server = MockServer::start().await; - mount_search(&server, json!({"tests": []})).await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["ghost".to_string()]; - let opts = TestsShowOptions { - repository: None, - test_names: &names, - token: Some("test-token"), - api_url: Some(&api_url), - pipeline_name: &[], - pipeline_name_exclude: &[], - job_name: &[], - job_name_exclude: &[], - per_page: None, - }; - let exit = run(opts, &mut cap.output).await.unwrap(); - assert_eq!(exit, ExitCode::Success); - }, - ) - .await; - } - - #[tokio::test] - async fn empty_search_in_human_mode_writes_no_match_to_stderr() { - let server = MockServer::start().await; - mount_search(&server, json!({"tests": []})).await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let names = vec!["ghost".to_string()]; - run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - - assert!(read(&cap.stderr).contains("no tests matched 'ghost'")); - assert_eq!(read(&cap.stdout), ""); - } - - #[tokio::test] - async fn single_match_fetches_details_and_renders_in_human_mode() { - let server = MockServer::start().await; - let id = test_id(1); - mount_search( - &server, - json!({ - "tests": [{ - "test_id": id, - "test_name": "test_login", - "pipeline_name": "ci", - "job_name": "unit", - }] - }), - ) - .await; - mount_details( - &server, - &id, - details_template(&id, "test_login", "healthy", "passed"), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let names = vec!["test_login".to_string()]; - let exit = run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - - assert_eq!(exit, ExitCode::Success); - let out = read(&cap.stdout); - assert!(out.contains("test_login"), "missing test name in {out:?}"); - assert!(out.contains("● healthy"), "missing health line in {out:?}"); - assert!(out.contains("✓ passed"), "missing conclusion in {out:?}"); - assert!( - out.contains("success ratio: 92.0%"), - "missing ratio in {out:?}" - ); - assert!( - !out.contains("flakiness"), - "flakiness line must be hidden when flaky_detection_enabled=false, got {out:?}" - ); - assert!( - !out.contains("disabled"), - "no `disabled` placeholder allowed, got {out:?}" - ); - } - - #[tokio::test] - async fn batch_preserves_search_order_even_when_responses_arrive_out_of_order() { - let server = MockServer::start().await; - let id_a = test_id(1); - let id_b = test_id(2); - let id_c = test_id(3); - - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id_a, "test_name": "alpha", "pipeline_name": "ci", "job_name": "j"}, - {"test_id": id_b, "test_name": "bravo", "pipeline_name": "ci", "job_name": "j"}, - {"test_id": id_c, "test_name": "charlie", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - - let delayed = |id: &str, name: &str, ms: u64| { - details_template(id, name, "healthy", "passed") - .set_delay(std::time::Duration::from_millis(ms)) - }; - mount_details(&server, &id_a, delayed(&id_a, "alpha", 80)).await; - mount_details(&server, &id_b, delayed(&id_b, "bravo", 0)).await; - mount_details(&server, &id_c, delayed(&id_c, "charlie", 40)).await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["*".to_string()]; - run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - - let stdout = read(&cap.stdout); - let value: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let names: Vec<&str> = value["tests"] - .as_array() - .unwrap() - .iter() - .map(|t| t["test_name"].as_str().unwrap()) - .collect(); - assert_eq!(names, vec!["alpha", "bravo", "charlie"]); - } - - #[tokio::test] - async fn unhealthy_tests_still_exit_success() { - // `show` is a read command: broken and flaky tests are part of - // a successful display, not a command failure. Health is read - // from the payload, never from the exit code. - let server = MockServer::start().await; - let id_a = test_id(1); - let id_b = test_id(2); - let id_c = test_id(3); - - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id_a, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, - {"test_id": id_b, "test_name": "b", "pipeline_name": "ci", "job_name": "j"}, - {"test_id": id_c, "test_name": "c", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - mount_details( - &server, - &id_a, - details_template(&id_a, "a", "healthy", "passed"), - ) - .await; - mount_details( - &server, - &id_b, - details_template(&id_b, "b", "flaky", "passed"), - ) - .await; - mount_details( - &server, - &id_c, - details_template(&id_c, "c", "broken", "failed"), - ) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["*".to_string()]; - let exit = run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - assert_eq!( - exit, - ExitCode::Success, - "displaying unhealthy tests is still a successful show" - ); - } - - #[tokio::test] - async fn unknown_health_status_renders_and_exits_success() { - let server = MockServer::start().await; - let id = test_id(1); - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - mount_details( - &server, - &id, - details_template(&id, "a", "future_value_we_dont_know", "passed"), - ) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["a".to_string()]; - let exit = run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - assert_eq!(exit, ExitCode::Success); - let mut cap = captured(OutputMode::Human); - run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - assert!(read(&cap.stdout).contains("— unknown")); - } - - #[tokio::test] - async fn flaky_detection_enabled_true_renders_flakiness_line() { - let server = MockServer::start().await; - let id = test_id(1); - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - let mut details = details_json(&id, "a", "flaky", "passed"); - details["flaky_detection_enabled"] = json!(true); - details["flakiness_ratio"] = json!(0.12); - mount_details( - &server, - &id, - ResponseTemplate::new(200).set_body_json(details), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let names = vec!["a".to_string()]; - run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - let out = read(&cap.stdout); - assert!(out.contains("flakiness: 12.0%"), "got {out:?}"); - } - - #[tokio::test] - async fn optional_metadata_lines_rendered_when_present() { - let server = MockServer::start().await; - let id = test_id(1); - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id, "test_name": "test_login", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - let mut details = details_json(&id, "test_login", "healthy", "passed"); - details["test_framework"] = json!("pytest"); - details["test_framework_version"] = json!("8.3.2"); - details["test_programming_language"] = json!("python"); - details["test_filepath"] = json!("tests/auth.py"); - details["test_function_name"] = json!("test_login"); - details["last_success_at"] = json!("2026-05-06T08:00:00Z"); - details["first_failure_at"] = json!("2026-04-07T07:52:28Z"); - details["first_failure_commit"] = json!("9ee5a5183b0640220d009982ab62e336d3f64d0f"); - details["first_failure_pull"] = json!({ - "id": 3_496_538_490_u64, - "number": 28834, - "title": "chore(deps): update click", - "user": {"id": 29_139_614, "login": "renovate[bot]"}, - }); - mount_details( - &server, - &id, - ResponseTemplate::new(200).set_body_json(details), - ) - .await; - - let mut cap = captured(OutputMode::Human); - let api_url = server.uri(); - let names = vec!["test_login".to_string()]; - run(options(&api_url, &names), &mut cap.output) - .await - .unwrap(); - let out = read(&cap.stdout); - for needle in [ - "language: python", - "framework: pytest 8.3.2", - "file: tests/auth.py", - "function: test_login", - "last success: 2026-05-06 08:00 UTC", - "first failure: 2026-04-07 07:52 UTC", - "commit 9ee5a51 in #28834", - "by renovate[bot]", - ] { - assert!( - out.contains(needle), - "expected {needle:?} in output:\n{out}" - ); - } - } - - #[tokio::test] - async fn search_query_includes_names_and_filters_and_omits_absent_ones() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path_matcher("/v1/ci/owner/repositories/repo/search/tests")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"tests": []}))) - .expect(1) - .mount(&server) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["alpha".to_string(), "*beta*".to_string()]; - let pipelines = vec!["e2e".to_string()]; - let job_excludes = vec!["lint".to_string()]; - - let opts = TestsShowOptions { - repository: Some("owner/repo"), - test_names: &names, - token: Some("t"), - api_url: Some(&api_url), - pipeline_name: &pipelines, - pipeline_name_exclude: &[], - job_name: &[], - job_name_exclude: &job_excludes, - per_page: Some(20), - }; - run(opts, &mut cap.output).await.unwrap(); - - let received = server.received_requests().await.unwrap(); - assert_eq!(received.len(), 1); - let pairs: Vec<(String, String)> = received[0] - .url - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - assert_eq!( - pairs, - vec![ - ("test_name".into(), "alpha".into()), - ("test_name".into(), "*beta*".into()), - ("pipeline_name".into(), "e2e".into()), - ("job_name_exclude".into(), "lint".into()), - ("per_page".into(), "20".into()), - ], - ); - } - - #[tokio::test] - async fn details_endpoint_4xx_surfaces_as_error() { - let server = MockServer::start().await; - let id_a = test_id(1); - let id_b = test_id(2); - mount_search( - &server, - json!({ - "tests": [ - {"test_id": id_a, "test_name": "a", "pipeline_name": "ci", "job_name": "j"}, - {"test_id": id_b, "test_name": "b", "pipeline_name": "ci", "job_name": "j"}, - ] - }), - ) - .await; - mount_details( - &server, - &id_a, - ResponseTemplate::new(404).set_body_string("not found"), - ) - .await; - mount_details( - &server, - &id_b, - details_template(&id_b, "b", "healthy", "passed"), - ) - .await; - - let mut cap = captured(OutputMode::Json); - let api_url = server.uri(); - let names = vec!["*".to_string()]; - let err = run(options(&api_url, &names), &mut cap.output) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - } - - #[tokio::test] - async fn invalid_repository_format_is_a_configuration_error() { - let mut cap = captured(OutputMode::Human); - let names = vec!["a".to_string()]; - let opts = TestsShowOptions { - repository: Some("not-a-slash-pair"), - test_names: &names, - token: Some("t"), - api_url: Some("https://example.invalid"), - pipeline_name: &[], - pipeline_name_exclude: &[], - job_name: &[], - job_name_exclude: &[], - per_page: None, - }; - let err = run(opts, &mut cap.output).await.unwrap_err(); - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - } -} diff --git a/crates/mergify-cli/Cargo.toml b/crates/mergify-cli/Cargo.toml deleted file mode 100644 index 3bbdf97d..00000000 --- a/crates/mergify-cli/Cargo.toml +++ /dev/null @@ -1,65 +0,0 @@ -[package] -name = "mergify-cli" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "mergify CLI — Rust implementation." -publish = false - -[[bin]] -name = "mergify" -path = "src/main.rs" - -[dependencies] -chrono = { workspace = true } -clap = { workspace = true } -clap_complete = { workspace = true } -clap_mangen = { workspace = true } -mergify-auth = { path = "../mergify-auth" } -mergify-ci = { path = "../mergify-ci" } -mergify-config = { path = "../mergify-config" } -mergify-core = { path = "../mergify-core" } -mergify-events = { path = "../mergify-events" } -mergify-freeze = { path = "../mergify-freeze" } -mergify-queue = { path = "../mergify-queue" } -mergify-stack = { path = "../mergify-stack" } -# Shared TTY/color palette — the binary resolves `--color` once at -# startup and records it via `mergify_tui::set_color_choice`. -mergify-tui = { path = "../mergify-tui" } -# `mergify self-update`: hits the GitHub Releases API for the -# latest tag, downloads the asset matching the current target, -# verifies it against `SHA256SUMS` (same format the curl|sh -# installer trusts), and atomically swaps the running binary. Uses -# `reqwest` directly rather than `mergify-core::http::Client`, which -# is path/JSON/auth-oriented; self-update does unauthenticated -# *binary* downloads across two hosts (api.github.com + asset CDN), -# does its own retry, and maps GitHub failures to the GitHub-API -# exit code. `self_replace` handles the Windows -# can't-rename-over-a-running-exe dance cross-platform. -reqwest = { workspace = true } -self-replace = { workspace = true } -sha2 = { workspace = true } -tempfile = { workspace = true } -# Backs the `_internal dump-cli-schema` generator (serializing the -# clap command tree) and the Python-migration helpers (`_internal -# stack-local-commits` et al.). -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -insta = { workspace = true } -regex = { workspace = true } -serde_yaml_ng = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-cli/build.rs b/crates/mergify-cli/build.rs deleted file mode 100644 index 8aef6e32..00000000 --- a/crates/mergify-cli/build.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Plumb the release version through to the binary. -//! -//! The release tag (calver, `2026.10.1`) is the source of truth -//! for the version, so `Cargo.toml` is pinned at the `0.0.0` -//! placeholder and the real version comes in via the -//! `MERGIFY_RELEASE_VERSION` env var the release workflow sets -//! from `$GITHUB_REF`. This script normalises that input into a -//! single rustc-env (`MERGIFY_CLI_VERSION`) the binary reads -//! unconditionally — empty / unset both collapse to -//! `CARGO_PKG_VERSION` here, so `main.rs` is a one-liner that -//! can't accidentally surface an empty version string. - -fn main() { - // Rebuild when the env var changes so a release rebuild after a - // dev build actually picks up the new value. - println!("cargo:rerun-if-env-changed=MERGIFY_RELEASE_VERSION"); - // A build script's environment is cargo's, handed to it for this - // one invocation — not the process environment `clippy.toml` - // guards, and not reachable through `mergify_core::env`, which a - // build script cannot depend on. - #[allow(clippy::disallowed_methods)] - let resolved = std::env::var("MERGIFY_RELEASE_VERSION") - .ok() - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); - println!("cargo:rustc-env=MERGIFY_CLI_VERSION={resolved}"); -} diff --git a/crates/mergify-cli/src/cli_schema.rs b/crates/mergify-cli/src/cli_schema.rs deleted file mode 100644 index 384146a7..00000000 --- a/crates/mergify-cli/src/cli_schema.rs +++ /dev/null @@ -1,310 +0,0 @@ -//! Machine-readable CLI schema — the CLI's equivalent of the API's -//! `OpenAPI` spec. [`build`] walks the clap `Command` tree rooted at -//! [`crate::CliRoot`] and emits every command, subcommand, and -//! argument (descriptions, flags, defaults, value sets) as JSON. The -//! docs site renders that JSON the same way it renders the `OpenAPI` -//! spec, so the published reference can never drift from the binary: -//! every field is sourced from a clap getter that the derive macro -//! populates from the Rust doc comments and `#[arg]`/`#[command]` -//! attributes — there is no hand-maintained docs metadata. -//! -//! The exit-code contract rides alongside the command tree: the -//! top-level `exitCodes` block is sourced from [`mergify_core::ExitCode`] -//! (the single source of truth, compat-tested), so it can't drift from -//! the binary either. The per-command "which code can this command -//! return" mapping is *not* introspectable and stays in the compat-test -//! harness — it deliberately doesn't live here. - -use std::collections::BTreeSet; - -use clap::CommandFactory; -use mergify_core::ExitCode; -use serde::Serialize; - -use crate::CliRoot; - -/// Internal contract version. Bump when a field is renamed or removed -/// so the docs renderer can move in lockstep. -const SCHEMA_VERSION: u32 = 1; - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CliSchema { - schema_version: u32, - generator: &'static str, - cli: CliInfo, - exit_codes: Vec, - command: CommandNode, -} - -#[derive(Serialize)] -struct ExitCodeNode { - code: u8, - name: &'static str, - description: &'static str, -} - -#[derive(Serialize)] -struct CliInfo { - name: String, - version: &'static str, - about: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct CommandNode { - /// Leaf name, e.g. `add`. - name: String, - /// Full invocation path, e.g. `["mergify", "tests", "quarantines", "add"]`. - /// Drives the docs slug and grouping. - path: Vec, - about: Option, - long_about: Option, - usage: String, - aliases: Vec, - subcommand_required: bool, - /// Always `"native"` — every command is introspected from - /// clap now that the Python tree is gone. Kept as a field so - /// the consumer-side schema doesn't have to special-case its - /// removal across `SCHEMA_VERSION` bumps. - source: &'static str, - args: Vec, - commands: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct ArgNode { - id: String, - /// `positional`, `flag` (takes no value), or `option`. - kind: &'static str, - short: Option, - long: Option, - value_names: Vec, - help: Option, - long_help: Option, - required: bool, - global: bool, - default: Option, - possible_values: Vec, - /// clap's `ValueRange` rendering, e.g. `1`, `0..=1`, `1..`. - num_args: Option, - env: Option, - value_hint: Option<&'static str>, -} - -#[derive(Serialize)] -struct PossibleValueNode { - name: String, - help: Option, -} - -/// Build the schema for the whole CLI. -pub fn build() -> CliSchema { - let mut root = CliRoot::command(); - // clap injects argument defaults (num_args, actions, value hints) - // and propagates global args only during `build()`; walking an - // unbuilt command yields null/wrong values for those fields. - root.build(); - - // Seed the path root from clap's configured name so `command.path[0]` - // can't drift from `cli.name`. - let command = command_node(&root, vec![root.get_name().to_string()], &BTreeSet::new()); - - let exit_codes = ExitCode::ALL - .iter() - .map(|code| ExitCodeNode { - code: code.as_u8(), - name: code.name(), - description: code.description(), - }) - .collect(); - - CliSchema { - schema_version: SCHEMA_VERSION, - generator: "mergify _internal dump-cli-schema", - cli: CliInfo { - name: root.get_name().to_string(), - version: crate::VERSION, - about: root.get_about().map(ToString::to_string), - }, - exit_codes, - command, - } -} - -/// Serialize the schema as pretty JSON with a trailing newline. -pub fn dump() -> String { - let mut s = serde_json::to_string_pretty(&build()).expect("CLI schema serializes"); - s.push('\n'); - s -} - -/// Print the schema to stdout. Entry point for `_internal dump-cli-schema`. -pub fn run() -> std::process::ExitCode { - print!("{}", dump()); - std::process::ExitCode::SUCCESS -} - -fn command_node( - cmd: &clap::Command, - path: Vec, - inherited_globals: &BTreeSet, -) -> CommandNode { - let about = cmd.get_about().map(ToString::to_string); - // clap returns None for `long_about` when only the short `///` - // line is set; mirror its own short→long fallback so the field is - // never spuriously empty. - let long_about = cmd - .get_long_about() - .map(ToString::to_string) - .or_else(|| about.clone()); - - // Each global arg is emitted once, at the command where it's - // declared; descendants inherit it (clap propagates it into their - // `get_arguments()` after `build()`) so we skip it there. - let mut globals_seen = inherited_globals.clone(); - let mut args = Vec::new(); - for arg in cmd.get_arguments() { - let id = arg.get_id().as_str(); - // clap's synthetic `--help`/`--version` and any `hide`-d arg - // are not part of the user-facing reference. - if id == "help" || id == "version" || arg.is_hide_set() { - continue; - } - if arg.is_global_set() { - if inherited_globals.contains(id) { - continue; - } - globals_seen.insert(id.to_string()); - } - args.push(arg_node(arg)); - } - - let commands = cmd - .get_subcommands() - // Skip `hide`-d commands and clap's synthetic `help` - // subcommand — the latter is universal plumbing, not part of - // the user-facing reference (same reasoning as the `--help` - // arg filter above). - .filter(|sub| !sub.is_hide_set() && sub.get_name() != "help") - .map(|sub| { - let mut child = path.clone(); - child.push(sub.get_name().to_string()); - command_node(sub, child, &globals_seen) - }) - .collect(); - - let usage = render_usage(cmd, &path); - - CommandNode { - name: cmd.get_name().to_string(), - path, - about, - long_about, - usage, - aliases: cmd.get_visible_aliases().map(ToString::to_string).collect(), - subcommand_required: cmd.is_subcommand_required_set(), - source: "native", - args, - commands, - } -} - -fn arg_node(arg: &clap::Arg) -> ArgNode { - let help = arg.get_help().map(ToString::to_string); - let long_help = arg - .get_long_help() - .map(ToString::to_string) - .or_else(|| help.clone()); - - let defaults: Vec = arg - .get_default_values() - .iter() - .map(|v| v.to_string_lossy().into_owned()) - .collect(); - let default = (!defaults.is_empty()).then(|| defaults.join(",")); - - let possible_values = arg - .get_possible_values() - .into_iter() - .filter(|pv| !pv.is_hide_set()) - .map(|pv| PossibleValueNode { - name: pv.get_name().to_string(), - help: pv.get_help().map(ToString::to_string), - }) - .collect(); - - let kind = arg_kind(arg); - // Flags take no value, but clap still derives a placeholder value - // name from the id (e.g. `DEBUG` for `--debug`) — drop it so the - // reference doesn't imply `--debug `. - let value_names = if kind == "flag" { - Vec::new() - } else { - arg.get_value_names() - .map(|names| names.iter().map(ToString::to_string).collect()) - .unwrap_or_default() - }; - - ArgNode { - id: arg.get_id().as_str().to_string(), - kind, - short: arg.get_short().map(|c| c.to_string()), - long: arg.get_long().map(ToString::to_string), - value_names, - help, - long_help, - required: arg.is_required_set(), - global: arg.is_global_set(), - default, - possible_values, - num_args: arg.get_num_args().map(|r| r.to_string()), - env: arg.get_env().map(|e| e.to_string_lossy().into_owned()), - value_hint: value_hint(arg), - } -} - -fn arg_kind(arg: &clap::Arg) -> &'static str { - use clap::ArgAction; - if arg.is_positional() { - "positional" - } else if matches!( - arg.get_action(), - ArgAction::SetTrue | ArgAction::SetFalse | ArgAction::Count - ) { - "flag" - } else { - "option" - } -} - -fn value_hint(arg: &clap::Arg) -> Option<&'static str> { - use clap::ValueHint; - Some(match arg.get_value_hint() { - ValueHint::Unknown => return None, - ValueHint::AnyPath => "anyPath", - ValueHint::FilePath => "filePath", - ValueHint::DirPath => "dirPath", - ValueHint::ExecutablePath => "executablePath", - ValueHint::CommandName => "commandName", - ValueHint::CommandString => "commandString", - ValueHint::CommandWithArguments => "commandWithArguments", - ValueHint::Username => "username", - ValueHint::Hostname => "hostname", - ValueHint::Url => "url", - ValueHint::EmailAddress => "emailAddress", - _ => "other", - }) -} - -/// Render the usage line, overriding the bin name so it shows the full -/// invocation path (`mergify tests quarantines add …`). `render_usage` -/// needs `&mut`, so clone — the walk stays immutable. -fn render_usage(cmd: &clap::Command, path: &[String]) -> String { - let mut cmd = cmd.clone().bin_name(path.join(" ")); - let usage = cmd.render_usage().to_string(); - // `render_usage` prefixes "Usage: "; the schema carries the bare line. - usage.strip_prefix("Usage: ").unwrap_or(&usage).to_string() -} diff --git a/crates/mergify-cli/src/main.rs b/crates/mergify-cli/src/main.rs deleted file mode 100644 index 3176ee33..00000000 --- a/crates/mergify-cli/src/main.rs +++ /dev/null @@ -1,5280 +0,0 @@ -//! `mergify` binary entry point. -//! -//! Every command is native — clap parses the full flag set and the -//! binary runs the matching [`NativeCommand`] in process. Any -//! invocation clap can't parse (unknown subcommand, missing -//! required flag, …) exits with clap's formatted error and exit -//! code 2; for unknown subcommands that includes a "did you mean -//! ``?" suggestion off clap's built-in Levenshtein -//! distance. - -use mergify_core::env; -use std::io::IsTerminal; -use std::path::PathBuf; -use std::process::ExitCode; - -use chrono::TimeDelta; -use clap::CommandFactory; -use clap::Parser; -use clap::Subcommand; -use mergify_ci::git_refs::Format as GitRefsFormat; -use mergify_ci::git_refs::GitRefsOptions; -use mergify_ci::junit_process::JunitProcessOptions; -use mergify_ci::scopes_send::ScopesSendOptions; -use mergify_ci::tests_quarantine::GetOptions; -use mergify_ci::tests_quarantine::QuarantineOptions; -use mergify_ci::tests_quarantine::QuarantinedOptions; -use mergify_ci::tests_quarantine::UnquarantineOptions; -use mergify_ci::tests_show::TestsShowOptions; -use mergify_config::simulate::SimulateOptions; -use mergify_core::OutputMode; -use mergify_core::StdioOutput; -use mergify_core::pull_request::PullRequestRef; -use mergify_events::list::ListOptions as EventsListOptions; -use mergify_freeze::common::parse_naive_datetime; -use mergify_freeze::create::CreateOptions as FreezeCreateOptions; -use mergify_freeze::delete::DeleteOptions as FreezeDeleteOptions; -use mergify_freeze::list::ListOptions as FreezeListOptions; -use mergify_freeze::update::UpdateOptions as FreezeUpdateOptions; -use mergify_queue::pause::PauseOptions; -use mergify_queue::show::ShowOptions; -use mergify_queue::status::StatusOptions; -use mergify_queue::unpause::UnpauseOptions; - -mod cli_schema; -mod self_update; - -/// User-visible CLI version. `build.rs` normalises the -/// `MERGIFY_RELEASE_VERSION` env var the release workflow sets -/// from `$GITHUB_REF` (unset or empty => `CARGO_PKG_VERSION`, -/// i.e. the `0.0.0` placeholder), and writes the result to the -/// `MERGIFY_CLI_VERSION` rustc-env. Reading the normalised var -/// directly means this side can't surface an empty string. -const VERSION: &str = env!("MERGIFY_CLI_VERSION"); - -fn main() -> ExitCode { - let argv: Vec = std::env::args().skip(1).collect(); - - // Test hook used by `test_binary_build.py` to verify the - // wheel-installed binary produces UTF-8 output (especially on - // Windows). The Python entry-point printed these markers from - // `cli.py::main` before any subcommand ran; now that the Rust - // binary handles `--help` natively the Python path is no - // longer guaranteed to fire, so the marker has to live here. - // The Rust binary is UTF-8 native on every platform — we don't - // need (or do) the Python `os.execv` re-exec trick — so we - // report `utf8_mode=1` on Windows (matching the post-re-exec - // expectation) and `utf8_mode=0` elsewhere. - if env::var_os("MERGIFY_CLI_TESTING_UTF8_MODE").is_some() { - let utf8_mode = u8::from(cfg!(target_os = "windows")); - println!("utf8_mode={utf8_mode}"); - println!("✅"); - } - - // Hidden flag — used by `mergify_cli/tests/queue/test_skill.py` - // (and any future cross-language test) to learn the set of - // commands the Rust binary handles natively without resorting - // to a hardcoded list that drifts. Format is one - // ` ` pair per line. - if argv.first().is_some_and(|a| a == "--list-native-commands") { - for (group, sub) in NATIVE_COMMANDS { - println!("{group} {sub}"); - } - return ExitCode::SUCCESS; - } - - match detect_dispatch(&argv) { - Dispatch::Native(cmd) => run_native(cmd), - } -} - -/// Result of `detect_dispatch`. Kept as a single-variant enum so the -/// match in `main` is exhaustive — every dispatch path lands on a -/// native command, but pattern-matching makes that explicit rather -/// than implicit. -enum Dispatch { - Native(NativeCommand), -} - -/// Single source of truth for the `(group, subcommand)` pairs the -/// Rust binary handles natively. Used by [`looks_native`] for argv -/// recognition and by the `--list-native-commands` hidden flag so -/// out-of-process tests can discover the list without hard-coding -/// it. Add new entries here when porting a command; the matching -/// `clap` `Subcommands` variant is what actually wires it up. -const NATIVE_COMMANDS: &[(&str, &str)] = &[ - ("auth", "login"), - ("auth", "logout"), - ("auth", "status"), - ("config", "validate"), - ("config", "simulate"), - ("ci", "scopes"), - ("ci", "scopes-send"), - ("ci", "git-refs"), - ("ci", "queue-info"), - ("ci", "junit-process"), - ("ci", "junit-upload"), - ("tests", "show"), - ("tests", "quarantines"), - ("queue", "pause"), - ("queue", "unpause"), - ("queue", "status"), - ("queue", "show"), - ("freeze", "list"), - ("freeze", "create"), - ("freeze", "update"), - ("freeze", "delete"), - ("stack", "checkout"), - ("stack", "drop"), - ("stack", "edit"), - ("stack", "fixup"), - ("stack", "hooks"), - ("stack", "list"), - ("stack", "move"), - ("stack", "new"), - ("stack", "note"), - ("stack", "open"), - ("stack", "push"), - ("stack", "reorder"), - ("stack", "reword"), - ("stack", "setup"), - ("stack", "squash"), - ("stack", "sync"), - // Internal helpers. Stay hidden from `--help` (see the - // `Subcommands::Internal` variant) but still need to be - // dispatchable. - ("_internal", "stack-local-commits"), - ("_internal", "stack-remote-changes"), - // Self-invocation target for the rebase-todo machinery — set - // as `GIT_SEQUENCE_EDITOR` before `git rebase -i` so we can - // rewrite the todo file in-process. Not a user-facing - // command; not stable. - ("_internal", "rebase-todo-rewrite"), - // Emits the machine-readable CLI schema the docs site renders - // into the command reference. Hidden; not a stable surface. - ("_internal", "dump-cli-schema"), - // Renders the roff man page to stdout, for packaging. Hidden. - ("_internal", "man"), -]; - -/// Native commands the Rust binary handles without delegating to -/// the Python shim. -enum NativeCommand { - /// `mergify auth login [--api-url URL]` — run the device grant - /// and store the credential it mints. - AuthLogin { - opts: AuthOpts, - no_browser: bool, - device_name: Option, - }, - /// `mergify auth logout [--api-url URL]` — revoke the stored - /// credential and forget it. - AuthLogout(AuthOpts), - /// `mergify auth status [--api-url URL]` — report whether there - /// is a credential, whose it is, and whether the API still - /// accepts it. - AuthStatus(AuthOpts), - ConfigValidate { - config_file: Option, - }, - ConfigSimulate(ConfigSimulateOpts), - CiScopes(CiScopesOpts), - CiScopesSend(CiScopesSendOpts), - CiGitRefs { - format: GitRefsFormat, - }, - CiQueueInfo, - CiJunitProcess(CiJunitProcessOpts), - /// Deprecated alias for `CiJunitProcess`. Same orchestrator, - /// same args; the dispatcher prints a deprecation warning to - /// stderr before running. Matches Python's `deprecated=...` - /// click decorator on `ci junit-upload`. - CiJunitUpload(CiJunitProcessOpts), - TestsShow(TestsShowOpts), - TestsQuarantine(TestsQuarantineOpts), - TestsUnquarantine(TestsUnquarantineOpts), - TestsQuarantineGet(TestsQuarantineGetOpts), - TestsQuarantined(TestsQuarantinedOpts), - QueuePause(QueuePauseOpts), - QueueUnpause(QueueUnpauseOpts), - QueueStatus(QueueStatusOpts), - QueueShow(QueueShowOpts), - Events(EventsOpts), - FreezeList(FreezeListOpts), - FreezeCreate(FreezeCreateOpts), - FreezeUpdate(FreezeUpdateOpts), - FreezeDelete(FreezeDeleteOpts), - /// `_internal stack-local-commits --base --head ` — - /// Python migration helper. Runs `git log` for the stack - /// range, parses each commit's `Change-Id:` trailer, prints - /// the result as a JSON array. Used by `mergify_cli/stack/changes.py` - /// while the surrounding stack discovery logic is still - /// Python. Wire format is not stable. - InternalStackLocalCommits(InternalStackLocalCommitsOpts), - /// `_internal stack-remote-changes --github-server URL --token T - /// --user U --repo R --stack-prefix P --author A` — Python - /// migration helper. Looks up the open + merged PRs on the - /// stack's live branches, groups them by Change-Id, prints a - /// JSON array of `{change_id, pull}` records. Wire format is - /// not stable. - InternalStackRemoteChanges(InternalStackRemoteChangesOpts), - /// `mergify stack new [--base REMOTE/BRANCH] - /// [--checkout/--no-checkout]` — create a new stack branch - /// tracking the resolved trunk. - StackNew(StackNewOpts), - /// `mergify stack note [] [-m ] [--append] - /// [--remove]` — attach/append/remove the "why was this commit - /// amended" note on `refs/notes/mergify/stack`. - StackNote(StackNoteOpts), - /// `mergify stack edit []` — pause an interactive - /// rebase at the target commit so the user can amend it. - StackEdit(StackEditOpts), - /// `mergify stack drop ... [--dry-run]` — drop one or - /// more commits from the stack via the rebase-todo machinery. - StackDrop(StackDropOpts), - /// `mergify stack fixup ... [--dry-run]` — fold one - /// or more commits into their parents via the rebase-todo - /// machinery. - StackFixup(StackFixupOpts), - /// `mergify stack reword [-m ] [--dry-run]` — - /// change a commit's message in place. - StackReword(StackRewordOpts), - /// `mergify stack reorder ... [--dry-run]` — rebase - /// the stack with the requested commit order. - StackReorder(StackReorderOpts), - /// `mergify stack move [] - /// [--dry-run]` — move a single commit within the stack. - StackMove(StackMoveOpts), - /// `mergify stack squash ... into [-m ] - /// [--dry-run]` — fold several commits into a target, - /// reordering them adjacent first. - StackSquash(StackSquashOpts), - /// `mergify stack checkout ` — fetch a stack of - /// pull requests from GitHub and create a local branch - /// tracking the leaf head. - StackCheckout(StackCheckoutOpts), - /// `mergify stack sync [--dry-run]` — rebase the stack onto - /// trunk, dropping commits whose PR has merged. - StackSync(StackSyncOpts), - /// `mergify stack push [flags...]` — upsert the stack's PRs - /// on GitHub. Full orchestrator: walks local commits, plans - /// actions, optionally rebases on trunk, pushes branches, - /// upserts PRs + comments, and tears down orphans. - StackPush(StackPushOpts), - /// `mergify stack list [--json] [--verbose]` — show each - /// commit in the current stack with its PR + CI + review - /// state. - StackList(StackListOpts), - /// `mergify stack open []` — open the PR for a stack - /// commit in the default browser. - StackOpen(StackOpenOpts), - /// `mergify stack hooks [--setup] [--force] [-f]` — show the - /// status of the managed git hooks (or, with `--setup`, - /// install/upgrade them). - StackHooks(StackHooksOpts), - /// `mergify stack setup [--force] [--check]` — alias for - /// `stack hooks --setup`; `--check` reports status instead. - StackSetup(StackSetupOpts), - /// `_internal rebase-todo-rewrite --action - /// --sha ` — self-invocation target set as - /// `GIT_SEQUENCE_EDITOR` by the rebase-family stack - /// subcommands. Reads the rebase-todo at `TODO_PATH`, - /// applies the named transformation, writes it back in place. - /// Wire format is not stable. - InternalRebaseTodoRewrite(InternalRebaseTodoRewriteOpts), - /// `_internal dump-cli-schema` — serialize the clap command tree - /// to JSON for the docs site. Pure introspection; no async, no I/O - /// beyond stdout. - InternalDumpCliSchema, - /// `mergify completions ` — print a shell completion script - /// to stdout. Pure introspection. - Completions(clap_complete::Shell), - /// `_internal man` — render the roff man page to stdout for - /// packaging. Pure introspection. - InternalManPage, - /// `mergify self-update [--force] [--check]` — replace the - /// running binary with the latest release. - SelfUpdate(self_update::Options), -} - -struct StackEditOpts { - /// `None` for an interactive rebase (no commit pre-selected); - /// `Some(prefix)` to pause the rebase on the matching commit. - commit_prefix: Option, -} - -struct StackDropOpts { - commit_prefixes: Vec, - dry_run: bool, -} - -struct StackFixupOpts { - commit_prefixes: Vec, - dry_run: bool, -} - -struct StackRewordOpts { - commit_prefix: String, - message: Option, - dry_run: bool, -} - -struct StackReorderOpts { - commit_prefixes: Vec, - dry_run: bool, -} - -struct StackMoveOpts { - commit_prefix: String, - position: StackMovePosition, - target_prefix: Option, - dry_run: bool, -} - -#[derive(Clone, Copy, clap::ValueEnum)] -enum StackMovePosition { - First, - Last, - Before, - After, -} - -/// The whole surface of every `auth` subcommand: which deployment -/// to talk to. There is deliberately no `--token` — `auth login` -/// mints one, and the other two act on what it stored. -struct AuthOpts { - api_url: Option, -} - -struct StackSquashOpts { - src_prefixes: Vec, - target_prefix: String, - message: Option, - dry_run: bool, -} - -struct StackCheckoutOpts { - /// Stack branch name or pull request URL. - target: String, - repository: Option, - branch: Option, - dry_run: bool, - /// `Some((remote, branch))` from `--trunk REMOTE/BRANCH`; - /// `None` falls back to `trunk::get_trunk` at runtime. - trunk: Option<(String, String)>, - /// GitHub token; resolved via `mergify_core::auth::resolve_github_token` - /// when None. - token: Option, -} - -struct StackSyncOpts { - author: Option, - repository: Option, - branch_prefix: Option, - dry_run: bool, - trunk: Option<(String, String)>, - token: Option, -} - -#[allow( - clippy::struct_excessive_bools, - reason = "mirrors the Python CLI's flag surface 1:1" -)] -struct StackPushOpts { - author: Option, - repository: Option, - branch_prefix: Option, - trunk: Option<(String, String)>, - token: Option, - skip_rebase: bool, - force_rebase: bool, - next_only: bool, - dry_run: bool, - /// `None` = fall back to git config - /// `mergify-cli.stack-create-as-draft` at dispatch time. - create_as_draft: Option, - /// `None` = fall back to git config - /// `mergify-cli.stack-keep-pr-title-body` at dispatch time. - keep_pull_request_title_and_body: Option, - only_update_existing_pulls: bool, - /// `None` = fall back to git config - /// `mergify-cli.stack-revision-history` at dispatch time. - revision_history: Option, - no_verify: bool, - /// `None` = fall back to git config - /// `mergify-cli.stack-github-native` at dispatch time. - github_native: Option, -} - -struct StackListOpts { - author: Option, - repository: Option, - branch_prefix: Option, - trunk: Option<(String, String)>, - token: Option, - json: bool, - verbose: bool, -} - -struct StackOpenOpts { - commit: Option, - author: Option, - repository: Option, - branch_prefix: Option, - trunk: Option<(String, String)>, - token: Option, -} - -struct StackHooksOpts { - do_setup: bool, - force: bool, -} - -struct StackSetupOpts { - force: bool, - check: bool, -} - -struct InternalRebaseTodoRewriteOpts { - /// Which transformation to apply. New variants land with the - /// respective port slices (today: `edit`, `drop`, `fixup`, - /// `reword`, `exec-after`, `reorder`, `squash`). - action: InternalRebaseAction, - /// Target commit SHA — used by `edit`, `reword`, `exec-after`, - /// and (optionally) `squash` for the post-fixup exec. - sha: Option, - /// Comma-separated commit SHAs — used by `drop`, `fixup`, - /// `reorder`, `squash` (the full new order in this case). - shas: Option, - /// Comma-separated SHAs that should fold as `fixup` — used - /// by `squash`. - fixup_shas: Option, - /// Shell command to inject as an `exec` line — used by - /// `exec-after`, `squash`. - command: Option, - /// Path to the rebase-todo file git wrote. - todo_path: PathBuf, -} - -#[derive(Clone, Copy, clap::ValueEnum)] -enum InternalRebaseAction { - Edit, - Drop, - Fixup, - Reword, - ExecAfter, - Reorder, - Squash, -} - -struct StackNoteOpts { - /// Commit to target — `None` means HEAD. Accepts a SHA prefix, - /// a ref (`HEAD~1`, branch name, etc.), or a Change-Id prefix - /// (resolved against the stack walk). - commit: Option, - /// Inline message; `None` means "open `$GIT_EDITOR`". Mutually - /// exclusive with `remove`. - message: Option, - /// Concatenate to the existing note instead of replacing. - append: bool, - /// Remove the note. Mutually exclusive with `message` / - /// `append`. - remove: bool, -} - -struct StackNewOpts { - name: String, - /// `Some((remote, branch))` for an explicit `--base`; `None` - /// means "resolve the trunk". - base: Option<(String, String)>, - checkout: bool, -} - -struct InternalStackLocalCommitsOpts { - base: String, - head: String, - repo_dir: Option, -} - -struct InternalStackRemoteChangesOpts { - github_server: url::Url, - token: Option, - user: String, - repo: String, - stack_prefix: String, - author: String, -} - -struct ConfigSimulateOpts { - config_file: Option, - pull_request: PullRequestRef, - token: Option, - api_url: Option, -} - -struct CiScopesOpts { - config: Option, - base: Option, - head: Option, - write: Option, -} - -struct CiScopesSendOpts { - repository: Option, - pull_request: Option, - token: Option, - api_url: Option, - scopes: Vec, - scopes_json: Option, - scopes_file: Option, - file_deprecated: Option, - all_scopes: bool, - head_sha: Option, -} - -struct CiJunitProcessOpts { - api_url: Option, - token: Option, - repository: Option, - test_framework: Option, - test_language: Option, - tests_target_branch: Option, - test_exit_code: Option, - files: Vec, -} - -struct QueuePauseOpts { - repository: Option, - token: Option, - api_url: Option, - reason: String, - yes_i_am_sure: bool, -} - -struct QueueUnpauseOpts { - repository: Option, - token: Option, - api_url: Option, -} - -struct QueueStatusOpts { - repository: Option, - token: Option, - api_url: Option, - branch: Option, - output_json: bool, -} - -struct QueueShowOpts { - repository: Option, - token: Option, - api_url: Option, - pr_number: u64, - verbose: bool, - output_json: bool, -} - -struct EventsOpts { - repository: Option, - token: Option, - api_url: Option, - pr_number: Option, - since: Option, - event_types: Vec, - limit: usize, - output_json: bool, -} - -struct FreezeListOpts { - repository: Option, - token: Option, - api_url: Option, - output_json: bool, -} - -struct TestsShowOpts { - repository: Option, - test_names: Vec, - token: Option, - api_url: Option, - pipeline_name: Vec, - pipeline_name_exclude: Vec, - job_name: Vec, - job_name_exclude: Vec, - per_page: Option, - json: bool, -} - -struct TestsQuarantineOpts { - repository: Option, - test_name: String, - reason: String, - branch: Option, - token: Option, - api_url: Option, - json: bool, -} - -struct TestsUnquarantineOpts { - repository: Option, - name_or_id: String, - token: Option, - api_url: Option, - json: bool, -} - -struct TestsQuarantineGetOpts { - repository: Option, - name_or_id: String, - token: Option, - api_url: Option, - json: bool, -} - -struct TestsQuarantinedOpts { - repository: Option, - token: Option, - api_url: Option, - json: bool, -} - -struct FreezeCreateOpts { - repository: Option, - token: Option, - api_url: Option, - reason: String, - timezone: Option, - start: Option, - end: Option, - matching_conditions: Vec, - exclude_conditions: Vec, -} - -struct FreezeUpdateOpts { - repository: Option, - token: Option, - api_url: Option, - freeze_id: String, - reason: Option, - timezone: Option, - start: Option, - end: Option, - matching_conditions: Option>, - exclude_conditions: Option>, -} - -struct FreezeDeleteOpts { - repository: Option, - token: Option, - api_url: Option, - freeze_id: String, - delete_reason: Option, -} - -/// Parse `argv` with clap and return the resolved native command. -/// -/// Any clap parse failure — unknown subcommand, missing required -/// argument, bad flag value — prints clap's formatted error -/// (including the built-in "did you mean ``?" suggestion -/// for unknown subcommands) and exits with clap's status code. -/// `--help` / `--version` also flow through `err.exit()` which -/// prints to stdout and exits 0. -#[allow(clippy::too_many_lines)] // mostly mechanical match arms -fn detect_dispatch(argv: &[String]) -> Dispatch { - let parsed = match CliRoot::try_parse_from( - std::iter::once("mergify".to_string()).chain(argv.iter().cloned()), - ) { - Ok(parsed) => parsed, - Err(err) => err.exit(), - }; - // Resolve the color preference once, before any command builds a - // theme via `Theme::detect`. - // Exported-but-empty is not set: a workflow writing - // `NO_COLOR: ${{ inputs.no_color }}` with no input exports the - // empty string, and says that does not - // count. Same rule as every other variable this CLI reads. - let color = resolve_color_choice( - parsed.color, - non_empty("NO_COLOR"), - non_empty("FORCE_COLOR") || non_empty("CLICOLOR_FORCE"), - ); - mergify_tui::set_color_choice(color); - init_tracing(parsed.verbose, parsed.debug, color); - dispatch_from_parsed(parsed) -} - -/// Install the tracing subscriber, writing structured logs to stderr -/// so stdout stays pipeable. The level comes from `-v` (info / debug / -/// trace), with `--debug` flooring at debug; an explicit `RUST_LOG` -/// overrides both. Only our own crates are raised — third-party deps -/// stay at `warn` so `-vv` doesn't drown in hyper/reqwest noise. -fn init_tracing(verbose: u8, debug: bool, color: mergify_tui::ColorChoice) { - use tracing_subscriber::EnvFilter; - - let level = match verbose { - 0 if debug => "debug", - 0 => "warn", - 1 => "info", - 2 => "debug", - _ => "trace", - }; - let directives = format!( - "warn,mergify_cli={level},mergify_core={level},mergify_stack={level},\ - mergify_ci={level},mergify_queue={level},mergify_freeze={level},\ - mergify_config={level},mergify_tui={level},mergify_auth={level}" - ); - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(directives)); - let _ = tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .with_ansi(ansi_on_stderr(color)) - .try_init(); -} - -/// Build the run options for `quarantines add`. -fn quarantine_opts(args: TestsQuarantineCliArgs) -> TestsQuarantineOpts { - TestsQuarantineOpts { - repository: args.repository, - test_name: args.test_name, - reason: args.reason, - branch: args.branch, - token: args.token, - api_url: args.api_url, - json: args.json, - } -} - -/// Build the run options for `quarantines remove`. -fn unquarantine_opts(args: TestsUnquarantineCliArgs) -> TestsUnquarantineOpts { - TestsUnquarantineOpts { - repository: args.repository, - name_or_id: args.name_or_id, - token: args.token, - api_url: args.api_url, - json: args.json, - } -} - -/// Build the run options for `quarantines list`. -fn quarantined_opts(args: TestsQuarantinedCliArgs) -> TestsQuarantinedOpts { - TestsQuarantinedOpts { - repository: args.repository, - token: args.token, - api_url: args.api_url, - json: args.json, - } -} - -/// Build the run options for `quarantines get`. -fn quarantine_get_opts(args: TestsQuarantineGetCliArgs) -> TestsQuarantineGetOpts { - TestsQuarantineGetOpts { - repository: args.repository, - name_or_id: args.name_or_id, - token: args.token, - api_url: args.api_url, - json: args.json, - } -} - -#[allow(clippy::too_many_lines)] // mostly mechanical match arms -fn dispatch_from_parsed(parsed: CliRoot) -> Dispatch { - let _ = parsed.debug; // already consumed by init_tracing(); ignored during dispatch - match parsed.command { - Subcommands::Stack(StackArgs { command }) => match command { - StackSubcommand::New(cli) => Dispatch::Native(NativeCommand::StackNew(cli.into())), - StackSubcommand::Note(cli) => Dispatch::Native(NativeCommand::StackNote(cli.into())), - StackSubcommand::Edit(cli) => Dispatch::Native(NativeCommand::StackEdit(cli.into())), - StackSubcommand::Drop(cli) => Dispatch::Native(NativeCommand::StackDrop(cli.into())), - StackSubcommand::Fixup(cli) => Dispatch::Native(NativeCommand::StackFixup(cli.into())), - StackSubcommand::Reword(cli) => { - Dispatch::Native(NativeCommand::StackReword(cli.into())) - } - StackSubcommand::Reorder(cli) => { - Dispatch::Native(NativeCommand::StackReorder(cli.into())) - } - StackSubcommand::Move(cli) => Dispatch::Native(NativeCommand::StackMove(cli.into())), - StackSubcommand::Squash(cli) => match StackSquashOpts::try_from(cli) { - Ok(opts) => Dispatch::Native(NativeCommand::StackSquash(opts)), - Err(msg) => CliRoot::command() - .error(clap::error::ErrorKind::ValueValidation, msg) - .exit(), - }, - StackSubcommand::Checkout(cli) => { - Dispatch::Native(NativeCommand::StackCheckout(cli.into())) - } - StackSubcommand::Sync(cli) => Dispatch::Native(NativeCommand::StackSync(cli.into())), - StackSubcommand::Push(cli) => Dispatch::Native(NativeCommand::StackPush(cli.into())), - StackSubcommand::List(cli) => { - Dispatch::Native(NativeCommand::StackList(StackListOpts { - verbose: parsed.verbose > 0, - ..cli.into() - })) - } - StackSubcommand::Open(cli) => Dispatch::Native(NativeCommand::StackOpen(cli.into())), - StackSubcommand::Hooks(cli) => Dispatch::Native(NativeCommand::StackHooks(cli.into())), - StackSubcommand::Setup(cli) => Dispatch::Native(NativeCommand::StackSetup(cli.into())), - }, - Subcommands::SelfUpdate(cli) => Dispatch::Native(NativeCommand::SelfUpdate(cli.into())), - Subcommands::Completions(cli) => Dispatch::Native(NativeCommand::Completions(cli.shell)), - Subcommands::Internal(InternalArgs { - command: - InternalSubcommand::StackLocalCommits(InternalStackLocalCommitsArgs { - base, - head, - repo_dir, - }), - }) => Dispatch::Native(NativeCommand::InternalStackLocalCommits( - InternalStackLocalCommitsOpts { - base, - head, - repo_dir, - }, - )), - Subcommands::Internal(InternalArgs { - command: - InternalSubcommand::StackRemoteChanges(InternalStackRemoteChangesArgs { - github_server, - token, - user, - repo, - stack_prefix, - author, - }), - }) => Dispatch::Native(NativeCommand::InternalStackRemoteChanges( - InternalStackRemoteChangesOpts { - github_server, - token, - user, - repo, - stack_prefix, - author, - }, - )), - Subcommands::Internal(InternalArgs { - command: - InternalSubcommand::RebaseTodoRewrite(InternalRebaseTodoRewriteArgs { - action, - sha, - shas, - fixup_shas, - command, - todo_path, - }), - }) => Dispatch::Native(NativeCommand::InternalRebaseTodoRewrite( - InternalRebaseTodoRewriteOpts { - action, - sha, - shas, - fixup_shas, - command, - todo_path, - }, - )), - Subcommands::Internal(InternalArgs { - command: InternalSubcommand::DumpCliSchema, - }) => Dispatch::Native(NativeCommand::InternalDumpCliSchema), - Subcommands::Internal(InternalArgs { - command: InternalSubcommand::Man, - }) => Dispatch::Native(NativeCommand::InternalManPage), - Subcommands::Ci(CiArgs { - command: - CiSubcommand::Scopes(ScopesCliArgs { - config, - base, - head, - write, - }), - }) => Dispatch::Native(NativeCommand::CiScopes(CiScopesOpts { - config, - base, - head, - write, - })), - Subcommands::Ci(CiArgs { - command: - CiSubcommand::JunitProcess(JunitProcessCliArgs { - api_url, - token, - repository, - test_framework, - test_language, - tests_target_branch, - test_exit_code, - files, - }), - }) => Dispatch::Native(NativeCommand::CiJunitProcess(CiJunitProcessOpts { - api_url, - token, - repository, - test_framework, - test_language, - tests_target_branch, - test_exit_code, - files, - })), - Subcommands::Ci(CiArgs { - command: - CiSubcommand::JunitUpload(JunitProcessCliArgs { - api_url, - token, - repository, - test_framework, - test_language, - tests_target_branch, - test_exit_code, - files, - }), - }) => Dispatch::Native(NativeCommand::CiJunitUpload(CiJunitProcessOpts { - api_url, - token, - repository, - test_framework, - test_language, - tests_target_branch, - test_exit_code, - files, - })), - Subcommands::Auth(AuthArgs { api_url, command }) => { - let opts = AuthOpts { api_url }; - Dispatch::Native(match command { - AuthSubcommand::Login(AuthLoginArgs { - no_browser, - device_name, - }) => NativeCommand::AuthLogin { - opts, - no_browser, - device_name, - }, - AuthSubcommand::Logout => NativeCommand::AuthLogout(opts), - AuthSubcommand::Status => NativeCommand::AuthStatus(opts), - }) - } - Subcommands::Config(ConfigArgs { - config_file, - command: ConfigSubcommand::Validate(_), - }) => Dispatch::Native(NativeCommand::ConfigValidate { config_file }), - Subcommands::Config(ConfigArgs { - config_file, - command: - ConfigSubcommand::Simulate(SimulateCliArgs { - pull_request, - token, - api_url, - }), - }) => Dispatch::Native(NativeCommand::ConfigSimulate(ConfigSimulateOpts { - config_file, - pull_request, - token, - api_url, - })), - Subcommands::Ci(CiArgs { - command: - CiSubcommand::ScopesSend(ScopesSendCliArgs { - repository, - pull_request, - token, - api_url, - scope, - scopes_json, - scopes_file, - file_deprecated, - all, - head_sha, - }), - }) => Dispatch::Native(NativeCommand::CiScopesSend(CiScopesSendOpts { - repository, - pull_request, - token, - api_url, - scopes: scope, - scopes_json, - scopes_file, - file_deprecated, - all_scopes: all, - head_sha, - })), - Subcommands::Ci(CiArgs { - command: CiSubcommand::GitRefs(GitRefsCliArgs { format }), - }) => Dispatch::Native(NativeCommand::CiGitRefs { format }), - Subcommands::Ci(CiArgs { - command: CiSubcommand::QueueInfo(QueueInfoCliArgs {}), - }) => Dispatch::Native(NativeCommand::CiQueueInfo), - Subcommands::Tests(TestsArgs { - command: - TestsSubcommand::Show(TestsShowCliArgs { - repository, - test_names, - token, - api_url, - pipeline_name, - pipeline_name_exclude, - job_name, - job_name_exclude, - per_page, - json, - }), - }) => Dispatch::Native(NativeCommand::TestsShow(TestsShowOpts { - repository, - test_names, - token, - api_url, - pipeline_name, - pipeline_name_exclude, - job_name, - job_name_exclude, - per_page, - json, - })), - Subcommands::Tests(TestsArgs { - command: TestsSubcommand::Quarantines(TestsQuarantinesArgs { command }), - }) => Dispatch::Native(match command { - QuarantinesSubcommand::Add(args) => { - NativeCommand::TestsQuarantine(quarantine_opts(args)) - } - QuarantinesSubcommand::Remove(args) => { - NativeCommand::TestsUnquarantine(unquarantine_opts(args)) - } - QuarantinesSubcommand::Get(args) => { - NativeCommand::TestsQuarantineGet(quarantine_get_opts(args)) - } - QuarantinesSubcommand::List(args) => { - NativeCommand::TestsQuarantined(quarantined_opts(args)) - } - }), - Subcommands::Queue(QueueArgs { - repository, - token, - api_url, - command: - QueueSubcommand::Pause(PauseCliArgs { - reason, - yes_i_am_sure, - }), - }) => Dispatch::Native(NativeCommand::QueuePause(QueuePauseOpts { - repository, - token, - api_url, - reason, - yes_i_am_sure, - })), - Subcommands::Queue(QueueArgs { - repository, - token, - api_url, - command: QueueSubcommand::Unpause, - }) => Dispatch::Native(NativeCommand::QueueUnpause(QueueUnpauseOpts { - repository, - token, - api_url, - })), - Subcommands::Queue(QueueArgs { - repository, - token, - api_url, - command: QueueSubcommand::Status(StatusCliArgs { branch, json }), - }) => Dispatch::Native(NativeCommand::QueueStatus(QueueStatusOpts { - repository, - token, - api_url, - branch, - output_json: json, - })), - Subcommands::Queue(QueueArgs { - repository, - token, - api_url, - command: QueueSubcommand::Show(ShowCliArgs { pr_number, json }), - }) => Dispatch::Native(NativeCommand::QueueShow(QueueShowOpts { - repository, - token, - api_url, - pr_number, - verbose: parsed.verbose > 0, - output_json: json, - })), - Subcommands::Events(EventsCliArgs { - repository, - token, - api_url, - pr, - since, - r#type, - limit, - json, - }) => Dispatch::Native(NativeCommand::Events(EventsOpts { - repository, - token, - api_url, - pr_number: pr, - since, - event_types: r#type, - limit, - output_json: json, - })), - Subcommands::Freeze(FreezeArgs { - repository, - token, - api_url, - command: FreezeSubcommand::List(FreezeListCliArgs { json }), - }) => Dispatch::Native(NativeCommand::FreezeList(FreezeListOpts { - repository, - token, - api_url, - output_json: json, - })), - Subcommands::Freeze(FreezeArgs { - repository, - token, - api_url, - command: - FreezeSubcommand::Create(FreezeCreateCliArgs { - reason, - timezone, - condition, - start, - end, - exclude, - }), - }) => Dispatch::Native(NativeCommand::FreezeCreate(FreezeCreateOpts { - repository, - token, - api_url, - reason, - timezone, - start, - end, - matching_conditions: condition, - exclude_conditions: exclude, - })), - Subcommands::Freeze(FreezeArgs { - repository, - token, - api_url, - command: - FreezeSubcommand::Update(FreezeUpdateCliArgs { - freeze_id, - reason, - timezone, - condition, - start, - end, - exclude, - }), - }) => Dispatch::Native(NativeCommand::FreezeUpdate(FreezeUpdateOpts { - repository, - token, - api_url, - freeze_id, - reason, - timezone, - start, - end, - // Python's "include the list when the flag was passed - // at least once" maps to `Some(vec)` only when the user - // actually supplied a value. clap collects multiple - // `-c`/`-e` into a `Vec`, so an empty vec is - // indistinguishable from "flag never given" at this - // boundary — treat empty as `None` for parity. - matching_conditions: if condition.is_empty() { - None - } else { - Some(condition) - }, - exclude_conditions: if exclude.is_empty() { - None - } else { - Some(exclude) - }, - })), - Subcommands::Freeze(FreezeArgs { - repository, - token, - api_url, - command: - FreezeSubcommand::Delete(FreezeDeleteCliArgs { - freeze_id, - delete_reason, - }), - }) => Dispatch::Native(NativeCommand::FreezeDelete(FreezeDeleteOpts { - repository, - token, - api_url, - freeze_id, - delete_reason, - })), - } -} - -#[allow(clippy::too_many_lines)] // mostly mechanical match arms -/// Resolved bundle of the shared per-command preamble the GitHub- -/// API-backed stack subcommands (`list`, `open`, `sync`, -/// `checkout`, and eventually `push`) all need. Built by -/// [`resolve_stack_context`] from the per-command CLI flags. -struct StackContext { - client: mergify_core::HttpClient, - slug: mergify_stack::stack_context::RepoSlug, - author: String, - branch_prefix: String, - trunk: (String, String), -} - -async fn resolve_stack_context( - token: Option<&str>, - author: Option<&str>, - repository: Option<&str>, - trunk: Option<(String, String)>, - branch_prefix: Option, -) -> Result { - let token = mergify_core::auth::resolve_github_token(token)?; - let github_server = mergify_stack::stack_context::resolve_github_server(None)?; - let client = mergify_stack::remote_changes::default_client(github_server, &token)?; - let trunk = if let Some((remote, branch)) = trunk { - (remote, branch) - } else { - let t = mergify_stack::trunk::get_trunk(None).map_err(|e| { - mergify_core::CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Pass --trunk REMOTE/BRANCH." - )) - })?; - (t.remote, t.branch) - }; - let slug = mergify_stack::stack_context::resolve_repo(None, repository, &trunk.0)?; - let author = if let Some(a) = author { - a.to_string() - } else { - let user_payload: serde_json::Value = client.get("/user").await?; - user_payload - .get("login") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .ok_or_else(|| { - mergify_core::CliError::GitHubApi("/user response missing `login`".to_string()) - })? - }; - let branch_prefix = branch_prefix.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_branch_prefix(None, &author) - }); - Ok(StackContext { - client, - slug, - author, - branch_prefix, - trunk, - }) -} - -/// Resolve the stack branch prefix this machine pushes under, the -/// way every other stack command computes it. -/// -/// `stack checkout` needs it for naming only — never for finding -/// the stack — so it takes the configured value when there is one -/// and only falls back to `stack/` (one `GET /user`) when -/// there isn't. Getting it right is what keeps a multi-segment -/// branch name intact across a push/checkout/push round-trip. -async fn resolve_local_stack_prefix( - client: &mergify_core::HttpClient, -) -> Result { - if let Some(configured) = mergify_stack::stack_context::configured_branch_prefix(None) { - return Ok(configured); - } - let user_payload: serde_json::Value = client.get("/user").await?; - let login = user_payload - .get("login") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - mergify_core::CliError::GitHubApi("/user response missing `login`".to_string()) - })?; - // Not `resolve_default_branch_prefix` — the config lookup it - // starts with was just done above and came back empty. - Ok(mergify_stack::stack_context::default_branch_prefix(login)) -} - -/// Render `stack list` output to stdout in human-readable form. -/// Port of Python's `display_stack_list`. No colour codes — we -/// keep it plain so log scrapers don't have to strip ANSI; users -/// who want colour pipe through `bat -p` etc. -fn render_stack_list_text(out: &mergify_stack::commands::list::StackListOutput, verbose: bool) { - println!("\nStack on {} -> {}:\n", out.branch, out.trunk); - if out.entries.is_empty() { - println!(" No commits in stack"); - return; - } - for entry in &out.entries { - let short = &entry.commit_sha[..entry.commit_sha.len().min(7)]; - let status_label = match entry.status.as_str() { - "merged" => "MERGED", - "draft" => "DRAFT", - "open" => "OPEN", - "no_pr" => "NEW", - other => other, - }; - let conflict = if entry.mergeable == Some(false) { - " (conflicting)" - } else { - "" - }; - if let Some(num) = entry.pull_number { - println!( - " [{status_label}] #{num} {title} ({short}){conflict}", - title = entry.title, - ); - // CI + review render on a single 5-space-indented line - // joined by " | ", matching Python's display_stack_list - // (a part is omitted entirely when its status is unknown). - let ci_display = format_ci_display(entry, verbose); - let review_display = format_review_display(entry, verbose); - let parts: Vec<&str> = [ci_display.as_str(), review_display.as_str()] - .into_iter() - .filter(|p| !p.is_empty()) - .collect(); - if !parts.is_empty() { - println!(" {}", parts.join(" | ")); - } - if let Some(url) = &entry.pull_url { - println!(" {url}"); - } - } else { - println!(" [{status_label}] {title} ({short})", title = entry.title); - } - println!(); - } -} - -/// Build the `CI: …` cell for a stack-list entry. Port of Python's -/// `_format_ci_display`: empty when the status is unknown; in -/// verbose mode with per-check data it lists each check with a -/// status glyph, otherwise it shows the coarse status label. -fn format_ci_display( - entry: &mergify_stack::commands::list::StackListEntry, - verbose: bool, -) -> String { - if entry.ci_status == "unknown" { - return String::new(); - } - if verbose && !entry.ci_checks.is_empty() { - let checks = entry - .ci_checks - .iter() - .map(|c| { - let glyph = match c.status.as_str() { - "success" => "✓", - "failure" => "✗", - _ => "●", - }; - format!("{glyph} {}", c.name) - }) - .collect::>() - .join(", "); - return format!("CI: {checks}"); - } - let text = match entry.ci_status.as_str() { - "passing" => "✓ passing", - "failing" => "✗ failing", - "pending" => "● pending", - other => other, - }; - format!("CI: {text}") -} - -/// Build the `Review: …` cell for a stack-list entry. Port of -/// Python's `_format_review_display`. -fn format_review_display( - entry: &mergify_stack::commands::list::StackListEntry, - verbose: bool, -) -> String { - if entry.review_status == "unknown" { - return String::new(); - } - if verbose && !entry.reviews.is_empty() { - let reviewers = entry - .reviews - .iter() - .map(|r| match r.state.as_str() { - "APPROVED" => format!("✓ {}", r.user), - "CHANGES_REQUESTED" => format!("✗ {}", r.user), - _ => r.user.clone(), - }) - .collect::>() - .join(", "); - return format!("Review: {reviewers}"); - } - let text = match entry.review_status.as_str() { - "approved" => "✓ approved", - "changes_requested" => "✗ changes requested", - "pending" => "● pending", - other => other, - }; - format!("Review: {text}") -} - -/// Install / upgrade the git hooks. Prints one human line per -/// action performed, then a summary footer. Surfaces the same -/// outcome ``mergify_cli/stack/setup.py`` used to print. -fn run_stack_setup(force: bool) -> Result<(), mergify_core::CliError> { - use mergify_stack::commands::setup::HookAction; - let outcome = - mergify_stack::commands::setup::install(&mergify_stack::commands::setup::Options { - repo_dir: None, - force, - })?; - let mut any_legacy_needs_force = false; - for log in &outcome.logs { - for action in &log.actions { - match action { - HookAction::ScriptInstalled | HookAction::ScriptUpdated => { - println!( - "Updating managed hook script: mergify-hooks/{}.sh", - log.hook_name - ); - } - HookAction::WrapperInstalled => { - println!("Installing hook wrapper: {}", log.hook_name); - } - HookAction::WrapperMigrated => { - println!("Migrating legacy hook to new format: {}", log.hook_name); - } - HookAction::WrapperLegacyNeedsForce => { - println!( - "Found legacy hook: {} (run with --force to migrate)", - log.hook_name - ); - any_legacy_needs_force = true; - } - HookAction::ScriptUpToDate | HookAction::WrapperAlreadyInstalled => {} - } - } - } - if outcome.notes_display_ref_added { - println!("Added notes.displayRef = refs/notes/mergify/*"); - } - if outcome.notes_rewrite_ref_added { - println!("Added notes.rewriteRef = refs/notes/mergify/stack"); - } - if any_legacy_needs_force { - println!("Some hooks are legacy. Run 'mergify stack hooks --setup --force' to migrate."); - } - Ok(()) -} - -/// Print the hooks status table. Mirrors ``_print_hooks_status`` -/// in ``mergify_cli/stack/cli.py``. -fn render_hooks_status(status: &mergify_stack::commands::setup::HooksStatus) { - use mergify_stack::commands::setup::WrapperStatus; - let mut needs_setup = false; - let mut needs_force = false; - - println!("\nGit Hooks Status:\n"); - for h in &status.git_hooks { - println!(" {}:", h.hook_name); - let wrapper_line = match h.wrapper_status { - WrapperStatus::Installed => format!(" Wrapper: installed ({})", h.wrapper_path), - WrapperStatus::Legacy => { - needs_force = true; - " Wrapper: legacy (needs --force to migrate)".to_string() - } - WrapperStatus::Missing => { - needs_setup = true; - " Wrapper: not installed".to_string() - } - }; - println!("{wrapper_line}"); - if h.script_installed { - if h.script_needs_update { - println!(" Script: needs update ({})", h.script_path); - needs_setup = true; - } else { - println!(" Script: up to date ({})", h.script_path); - } - } else { - println!(" Script: not installed"); - needs_setup = true; - } - println!(); - } - if needs_setup || needs_force { - println!("Run 'mergify stack hooks --setup' to install/upgrade hooks."); - if needs_force { - println!("Run 'mergify stack hooks --setup --force' to force reinstall wrappers."); - } - } else { - println!("All hooks are up to date."); - } -} - -/// Absolute path to the running `mergify` binary. The rebase-family -/// stack subcommands set it as `GIT_SEQUENCE_EDITOR` so the spawned -/// `git rebase -i` re-invokes this binary to rewrite the todo file in -/// place. Keeps the original I/O error as a `caused by:` source. -fn mergify_self_exe() -> Result { - std::env::current_exe().map_err(|e| { - mergify_core::CliError::wrap( - "could not locate current binary path for GIT_SEQUENCE_EDITOR", - e, - ) - }) -} - -#[allow(clippy::too_many_lines)] // one match arm per native command -fn run_native(cmd: NativeCommand) -> ExitCode { - // Pure introspection — no async runtime, network, or shared output - // machinery. Handle it before spinning up tokio. - if matches!(cmd, NativeCommand::InternalDumpCliSchema) { - return cli_schema::run(); - } - // Completions and the man page are pure introspection over the - // clap tree — emit to stdout and exit before tokio spins up. - match cmd { - NativeCommand::Completions(shell) => { - let mut command = CliRoot::command(); - clap_complete::generate(shell, &mut command, "mergify", &mut std::io::stdout()); - return ExitCode::SUCCESS; - } - NativeCommand::InternalManPage => { - return match clap_mangen::Man::new(CliRoot::command()).render(&mut std::io::stdout()) { - Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("mergify: render man page: {e}"); - ExitCode::FAILURE - } - }; - } - _ => {} - } - - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - eprintln!("mergify: could not start async runtime: {e}"); - return ExitCode::FAILURE; - } - }; - - // The `tests` commands honor their `--json` flag through the - // shared `StdioOutput` machinery; everything else writes Human and - // manages its own JSON via run-time flags. - let mode = match &cmd { - NativeCommand::TestsShow(opts) if opts.json => OutputMode::Json, - NativeCommand::TestsQuarantine(opts) if opts.json => OutputMode::Json, - NativeCommand::TestsUnquarantine(opts) if opts.json => OutputMode::Json, - NativeCommand::TestsQuarantineGet(opts) if opts.json => OutputMode::Json, - NativeCommand::TestsQuarantined(opts) if opts.json => OutputMode::Json, - _ => OutputMode::Human, - }; - let mut output = StdioOutput::new(mode); - - let result: Result = rt.block_on(async { - match cmd { - // Handled above, before the runtime was built. - NativeCommand::InternalDumpCliSchema - | NativeCommand::Completions(_) - | NativeCommand::InternalManPage => { - unreachable!("introspection commands are handled before the runtime starts") - } - NativeCommand::AuthLogin { - opts, - no_browser, - device_name, - } => { - let store = mergify_core::CredentialStore::discover(); - let system_browser = mergify_auth::browser::SystemBrowser; - let browser: Option<&dyn mergify_auth::browser::Browser> = - if no_browser { None } else { Some(&system_browser) }; - mergify_auth::login::run( - mergify_auth::login::LoginOptions { - api_url: opts.api_url.as_deref(), - store: &store, - browser, - device_name: device_name.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success) - } - NativeCommand::AuthLogout(opts) => { - let store = mergify_core::CredentialStore::discover(); - mergify_auth::logout::run( - mergify_auth::logout::LogoutOptions { - api_url: opts.api_url.as_deref(), - store: &store, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success) - } - NativeCommand::AuthStatus(opts) => { - let store = mergify_core::CredentialStore::discover(); - mergify_auth::status::run( - mergify_auth::status::StatusOptions { - api_url: opts.api_url.as_deref(), - store: &store, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success) - } - NativeCommand::ConfigValidate { config_file } => { - mergify_config::validate::run(config_file.as_deref(), &mut output) - .await - .map(|()| mergify_core::ExitCode::Success) - } - NativeCommand::ConfigSimulate(opts) => mergify_config::simulate::run( - SimulateOptions { - pull_request: &opts.pull_request, - config_file: opts.config_file.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::CiScopesSend(opts) => mergify_ci::scopes_send::run( - ScopesSendOptions { - repository: opts.repository.as_deref(), - pull_request: opts.pull_request, - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - scopes: &opts.scopes, - scopes_json: opts.scopes_json.as_deref(), - scopes_file: opts.scopes_file.as_deref(), - deprecated_file: opts.file_deprecated.as_deref(), - all_scopes: opts.all_scopes, - head_sha: opts.head_sha.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::CiGitRefs { format } => { - mergify_ci::git_refs::run(&GitRefsOptions { format }, &mut output) - .map(|()| mergify_core::ExitCode::Success) - } - NativeCommand::CiQueueInfo => mergify_ci::queue_info::run(&mut output) - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::CiJunitProcess(opts) => { - mergify_ci::junit_process::run( - JunitProcessOptions { - api_url: opts.api_url.as_deref(), - token: opts.token.as_deref(), - repository: opts.repository.as_deref(), - test_framework: opts.test_framework.as_deref(), - test_language: opts.test_language.as_deref(), - tests_target_branch: opts.tests_target_branch.as_deref(), - test_exit_code: opts.test_exit_code, - files: &opts.files, - }, - &mut output, - ) - .await - } - NativeCommand::CiJunitUpload(opts) => { - // Match Python's `@ci.command(deprecated="...")` - // behavior: click prints a warning to stderr on - // first invocation before running the command body. - // The orchestrator is identical to junit-process, - // so we just forward. - eprintln!( - "DeprecationWarning: 'junit-upload' is deprecated, use `junit-process` instead.", - ); - mergify_ci::junit_process::run( - JunitProcessOptions { - api_url: opts.api_url.as_deref(), - token: opts.token.as_deref(), - repository: opts.repository.as_deref(), - test_framework: opts.test_framework.as_deref(), - test_language: opts.test_language.as_deref(), - tests_target_branch: opts.tests_target_branch.as_deref(), - test_exit_code: opts.test_exit_code, - files: &opts.files, - }, - &mut output, - ) - .await - } - NativeCommand::CiScopes(opts) => mergify_ci::scopes_detect::run( - mergify_ci::scopes_detect::ScopesOptions { - config: opts.config.as_deref(), - base: opts.base.as_deref(), - head: opts.head.as_deref(), - write: opts.write.as_deref(), - }, - &mut output, - ) - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::TestsShow(opts) => { - mergify_ci::tests_show::run( - TestsShowOptions { - repository: opts.repository.as_deref(), - test_names: &opts.test_names, - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - pipeline_name: &opts.pipeline_name, - pipeline_name_exclude: &opts.pipeline_name_exclude, - job_name: &opts.job_name, - job_name_exclude: &opts.job_name_exclude, - per_page: opts.per_page, - }, - &mut output, - ) - .await - } - NativeCommand::TestsQuarantine(opts) => { - mergify_ci::tests_quarantine::quarantine( - QuarantineOptions { - repository: opts.repository.as_deref(), - test_name: &opts.test_name, - reason: &opts.reason, - branch: opts.branch.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - } - NativeCommand::TestsUnquarantine(opts) => { - mergify_ci::tests_quarantine::unquarantine( - UnquarantineOptions { - repository: opts.repository.as_deref(), - name_or_id: &opts.name_or_id, - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - } - NativeCommand::TestsQuarantineGet(opts) => { - mergify_ci::tests_quarantine::get( - GetOptions { - repository: opts.repository.as_deref(), - name_or_id: &opts.name_or_id, - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - } - NativeCommand::TestsQuarantined(opts) => { - mergify_ci::tests_quarantine::quarantined( - QuarantinedOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - } - NativeCommand::QueuePause(opts) => mergify_queue::pause::run( - PauseOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - reason: &opts.reason, - yes_i_am_sure: opts.yes_i_am_sure, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::QueueUnpause(opts) => mergify_queue::unpause::run( - UnpauseOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::QueueStatus(opts) => mergify_queue::status::run( - StatusOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - branch: opts.branch.as_deref(), - output_json: opts.output_json, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::QueueShow(opts) => mergify_queue::show::run( - ShowOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - pr_number: opts.pr_number, - verbose: opts.verbose, - output_json: opts.output_json, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::Events(opts) => mergify_events::list::run( - EventsListOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - pr_number: opts.pr_number, - since: opts.since, - event_types: opts.event_types, - limit: opts.limit, - output_json: opts.output_json, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::FreezeList(opts) => mergify_freeze::list::run( - FreezeListOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - output_json: opts.output_json, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::FreezeCreate(opts) => mergify_freeze::create::run( - FreezeCreateOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - reason: &opts.reason, - timezone: opts.timezone.as_deref(), - start: opts.start, - end: opts.end, - matching_conditions: &opts.matching_conditions, - exclude_conditions: &opts.exclude_conditions, - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::FreezeUpdate(opts) => mergify_freeze::update::run( - FreezeUpdateOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - freeze_id: &opts.freeze_id, - reason: opts.reason.as_deref(), - timezone: opts.timezone.as_deref(), - start: opts.start, - end: opts.end, - matching_conditions: opts.matching_conditions.as_deref(), - exclude_conditions: opts.exclude_conditions.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::FreezeDelete(opts) => mergify_freeze::delete::run( - FreezeDeleteOptions { - repository: opts.repository.as_deref(), - token: opts.token.as_deref(), - api_url: opts.api_url.as_deref(), - freeze_id: &opts.freeze_id, - delete_reason: opts.delete_reason.as_deref(), - }, - &mut output, - ) - .await - .map(|()| mergify_core::ExitCode::Success), - NativeCommand::StackNew(opts) => { - let base = opts - .base - .map(|(remote, branch)| mergify_stack::commands::new::Base { - remote, - branch, - }); - let outcome = - mergify_stack::commands::new::run(None, &opts.name, base, opts.checkout)?; - if let Some(auto_set) = &outcome.upstream_auto_set { - // Yellow notice — matches `utils.get_trunk`'s - // print when it auto-sets upstream tracking. - eprintln!( - "Upstream not set for {branch}, automatically set to {remote}/{target}", - branch = auto_set.current_branch, - remote = auto_set.remote, - target = auto_set.branch, - ); - } - println!( - "Created branch '{name}' tracking {base}", - name = outcome.branch_name, - base = outcome.base_refspec, - ); - if outcome.checked_out { - println!("Switched to branch '{}'", outcome.branch_name); - } else { - println!( - "Run 'git checkout {}' to switch to the new branch", - outcome.branch_name, - ); - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackNote(opts) => { - let action = if opts.remove { - mergify_stack::commands::note::Action::Remove - } else if let Some(msg) = opts.message { - if opts.append { - mergify_stack::commands::note::Action::Append(msg) - } else { - mergify_stack::commands::note::Action::Set(msg) - } - } else { - mergify_stack::commands::note::Action::FromEditor - }; - let outcome = mergify_stack::commands::note::run( - None, - opts.commit.as_deref(), - action, - )?; - match outcome { - mergify_stack::commands::note::Outcome::Attached { sha, subject } => { - println!( - "Note attached to {short} {subject}.", - short = &sha[..sha.len().min(12)], - ); - } - mergify_stack::commands::note::Outcome::Removed { sha, subject } => { - println!( - "Note removed from {short} {subject}.", - short = &sha[..sha.len().min(12)], - ); - } - mergify_stack::commands::note::Outcome::NoNoteToRemove { - sha, - subject, - } => { - println!( - "No note on {short} {subject}.", - short = &sha[..sha.len().min(12)], - ); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackEdit(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::edit::run( - &mergify_stack::commands::edit::Options { - repo_dir: None, - commit_prefix: opts.commit_prefix.as_deref(), - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::edit::Outcome::PausedAt { .. } => { - // The "Editing commit:" notice is printed by - // edit::run before the rebase so it precedes - // git's output; here we add the post-rebase - // amend hint. - println!("Amend the commit, then run: git rebase --continue"); - } - mergify_stack::commands::edit::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - mergify_stack::commands::edit::Outcome::InteractiveCompleted => {} - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackDrop(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::drop::run( - &mergify_stack::commands::drop::Options { - repo_dir: None, - commit_prefixes: &opts.commit_prefixes, - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::drop::Outcome::Dropped { plan } => { - for line in mergify_stack::plan_display::render_plan("Drop plan:", &plan) { - println!("{line}"); - } - println!("Commits dropped successfully."); - } - mergify_stack::commands::drop::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Drop plan:", &plan) { - println!("{line}"); - } - println!("Dry run — no changes made"); - } - mergify_stack::commands::drop::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackFixup(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::fixup::run( - &mergify_stack::commands::fixup::Options { - repo_dir: None, - commit_prefixes: &opts.commit_prefixes, - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::fixup::Outcome::Squashed { plan } => { - for line in mergify_stack::plan_display::render_plan("Fixup plan:", &plan) { - println!("{line}"); - } - println!("Commits squashed successfully."); - } - mergify_stack::commands::fixup::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Fixup plan:", &plan) { - println!("{line}"); - } - println!("Dry run — no changes made"); - } - mergify_stack::commands::fixup::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackReword(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::reword::run( - &mergify_stack::commands::reword::Options { - repo_dir: None, - commit_prefix: &opts.commit_prefix, - message: opts.message.as_deref(), - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::reword::Outcome::Reworded { plan } => { - for line in mergify_stack::plan_display::render_plan("Reword plan:", &plan) { - println!("{line}"); - } - println!("Commit reworded successfully."); - } - mergify_stack::commands::reword::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Reword plan:", &plan) { - println!("{line}"); - } - println!("Dry run — no changes made"); - } - mergify_stack::commands::reword::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackReorder(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::reorder::run( - &mergify_stack::commands::reorder::Options { - repo_dir: None, - commit_prefixes: &opts.commit_prefixes, - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::reorder::Outcome::Reordered { plan } - | mergify_stack::commands::reorder::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Reorder plan:", &plan) - { - println!("{line}"); - } - if opts.dry_run { - println!("Dry run — no changes made"); - } else { - println!("Stack reordered successfully."); - } - } - mergify_stack::commands::reorder::Outcome::AlreadyInOrder => { - println!("Stack is already in the requested order"); - } - mergify_stack::commands::reorder::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackMove(opts) => { - let mergify_binary = mergify_self_exe()?; - let position = match opts.position { - StackMovePosition::First => mergify_stack::commands::move_cmd::Position::First, - StackMovePosition::Last => mergify_stack::commands::move_cmd::Position::Last, - StackMovePosition::Before => mergify_stack::commands::move_cmd::Position::Before, - StackMovePosition::After => mergify_stack::commands::move_cmd::Position::After, - }; - let outcome = mergify_stack::commands::move_cmd::run( - &mergify_stack::commands::move_cmd::Options { - repo_dir: None, - commit_prefix: &opts.commit_prefix, - position, - target_prefix: opts.target_prefix.as_deref(), - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::move_cmd::Outcome::Moved { plan } - | mergify_stack::commands::move_cmd::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Move plan:", &plan) { - println!("{line}"); - } - if opts.dry_run { - println!("Dry run — no changes made"); - } else { - println!("Commit moved successfully."); - } - } - mergify_stack::commands::move_cmd::Outcome::AlreadyInPosition => { - println!("Commit is already in the requested position"); - } - mergify_stack::commands::move_cmd::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackSquash(opts) => { - let mergify_binary = mergify_self_exe()?; - let outcome = mergify_stack::commands::squash::run( - &mergify_stack::commands::squash::Options { - repo_dir: None, - src_prefixes: &opts.src_prefixes, - target_prefix: &opts.target_prefix, - message: opts.message.as_deref(), - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - }, - )?; - match outcome { - mergify_stack::commands::squash::Outcome::Squashed { plan } - | mergify_stack::commands::squash::Outcome::DryRun { plan } => { - for line in mergify_stack::plan_display::render_plan("Squash plan:", &plan) { - println!("{line}"); - } - if opts.dry_run { - println!("Dry run — no changes made"); - } else { - println!("Commits squashed successfully."); - } - } - mergify_stack::commands::squash::Outcome::EmptyStack => { - println!("No commits in the stack"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackCheckout(opts) => { - let token = mergify_core::auth::resolve_github_token(opts.token.as_deref())?; - let github_server = - mergify_stack::stack_context::resolve_github_server(None)?; - let client = mergify_stack::remote_changes::default_client( - github_server, - &token, - )?; - - // Trunk: explicit --trunk wins; otherwise resolve. - let trunk = if let Some((remote, branch)) = opts.trunk { - (remote, branch) - } else { - let t = mergify_stack::trunk::get_trunk(None).map_err(|e| { - mergify_core::CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Pass --trunk REMOTE/BRANCH." - )) - })?; - (t.remote, t.branch) - }; - let remote = &trunk.0; - - // This machine's stack prefix, needed only to name - // the local branch and to spot a stack that isn't - // ours. Skipped entirely when `--branch` already - // names the branch, so the `/user` call it may - // need doesn't happen for nothing. - let local_stack_prefix = if opts.branch.is_some() { - None - } else { - Some(resolve_local_stack_prefix(&client).await?) - }; - - let outcome = mergify_stack::commands::checkout::run( - &mergify_stack::commands::checkout::Options { - repo_dir: None, - client: &client, - repository: opts.repository.as_deref(), - target: &opts.target, - local_branch: opts.branch.as_deref(), - local_stack_prefix: local_stack_prefix.as_deref(), - remote, - dry_run: opts.dry_run, - }, - ) - .await?; - - match outcome { - mergify_stack::commands::checkout::Outcome::NoStackedPrs => { - // An empty result is a not-found state, not a - // success — exit 3 so scripts can tell "checked - // out" from "nothing to check out" (matches - // `stack open`'s empty-stack handling). - println!("No stacked pull requests found"); - Ok(mergify_core::ExitCode::StackNotFound) - } - mergify_stack::commands::checkout::Outcome::CheckedOut { - chain, - created, - local_branch, - upstream, - stack_branch, - under_local_prefix, - } => { - println!("Stacked pull requests on '{stack_branch}':"); - for pr in &chain { - println!( - "* #{n} {title} {url}", - n = pr.number, - title = pr.title, - url = pr.html_url, - ); - println!(" {} -> {}", pr.base_ref, pr.head_ref); - } - if created { - println!( - "Checked out '{local_branch}' tracking {upstream}", - ); - } else { - // Dry-run: name the branch it would - // create, since that's half of what - // there is to preview. - println!( - "Would check out '{local_branch}' tracking {upstream}", - ); - } - if !under_local_prefix { - // Every other stack command scopes - // itself to your own prefix and login, - // so pushing from here would open a - // second, parallel set of PRs rather - // than update these ones. - println!( - "Note: '{stack_branch}' is not under your stack branch prefix. \ - You can work on these commits, but 'mergify stack push' from \ - '{local_branch}' would create a separate stack rather than \ - update these pull requests.", - ); - } - Ok(mergify_core::ExitCode::Success) - } - } - } - NativeCommand::StackSync(opts) => { - let token = mergify_core::auth::resolve_github_token(opts.token.as_deref())?; - let github_server = mergify_stack::stack_context::resolve_github_server(None)?; - let client = - mergify_stack::remote_changes::default_client(github_server, &token)?; - - let trunk = if let Some((remote, branch)) = opts.trunk { - (remote, branch) - } else { - let t = mergify_stack::trunk::get_trunk(None).map_err(|e| { - mergify_core::CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Pass --trunk REMOTE/BRANCH." - )) - })?; - (t.remote, t.branch) - }; - let slug = mergify_stack::stack_context::resolve_repo( - None, - opts.repository.as_deref(), - &trunk.0, - )?; - let author = if let Some(a) = opts.author.as_deref() { - a.to_string() - } else { - let user_payload: serde_json::Value = client.get("/user").await?; - user_payload - .get("login") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - .ok_or_else(|| { - mergify_core::CliError::GitHubApi( - "/user response missing `login`".to_string(), - ) - })? - }; - let branch_prefix = opts.branch_prefix.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_branch_prefix(None, &author) - }); - - let mergify_binary = mergify_self_exe()?; - - let outcome = mergify_stack::commands::sync::run( - &mergify_stack::commands::sync::Options { - repo_dir: None, - client: &client, - user: &slug.owner, - repo: &slug.repo, - author: &author, - branch_prefix: &branch_prefix, - trunk: (&trunk.0, &trunk.1), - dry_run: opts.dry_run, - mergify_binary: &mergify_binary, - // Direct `stack sync` shows git's rebase output. - quiet: false, - // Standalone sync fetches everything itself. - prefetched_remote_changes: None, - skip_trunk_fetch: false, - }, - ) - .await?; - - match outcome { - mergify_stack::commands::sync::Outcome::DryRun(status) => { - if status.all_merged() { - println!( - "All commits in the stack have been merged into {trunk_branch}.", - trunk_branch = trunk.1, - ); - println!( - "You can switch to {trunk_branch} with: git checkout {trunk_branch}", - trunk_branch = trunk.1, - ); - } else if status.up_to_date() { - println!("Stack is up to date."); - } else { - println!("Dry run: the following merged commits would be removed:"); - for m in &status.merged { - println!(" - {title} (#{num}, merged)", title = m.title, num = m.pull_number); - } - println!( - "\n{} commit(s) would remain in the stack.", - status.remaining.len() - ); - } - } - mergify_stack::commands::sync::Outcome::Synced { - status, - dropped_count, - } => { - if status.all_merged() { - println!( - "All commits in the stack have been merged into {trunk_branch}.", - trunk_branch = trunk.1, - ); - println!( - "You can switch to {trunk_branch} with: git checkout {trunk_branch}", - trunk_branch = trunk.1, - ); - } else if dropped_count == 0 { - println!("Stack is up to date."); - } else { - for m in &status.merged { - println!(" ✓ Dropped: {title} (#{num})", title = m.title, num = m.pull_number); - } - println!( - "Dropped {dropped_count} merged commit(s). {} commit(s) remaining in the stack.", - status.remaining.len() - ); - } - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackPush(opts) => { - let ctx = resolve_stack_context( - opts.token.as_deref(), - opts.author.as_deref(), - opts.repository.as_deref(), - opts.trunk.clone(), - opts.branch_prefix.clone(), - ) - .await?; - let github_server = mergify_stack::stack_context::resolve_github_server(None)?; - let mergify_binary = std::env::current_exe().map_err(|e| { - mergify_core::CliError::Generic(format!( - "could not locate current binary path: {e}" - )) - })?; - let github_server_str = github_server.as_str().trim_end_matches('/').to_string(); - let create_as_draft = opts.create_as_draft.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_create_as_draft(None) - }); - let keep_pull_request_title_and_body = - opts.keep_pull_request_title_and_body.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_keep_pr_title_body(None) - }); - let revision_history = opts.revision_history.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_revision_history(None) - }); - let github_native = opts.github_native.unwrap_or_else(|| { - mergify_stack::stack_context::resolve_default_github_native(None) - }); - let outcome = mergify_stack::commands::push::run( - &mergify_stack::commands::push::Options { - repo_dir: None, - client: &ctx.client, - mergify_binary: &mergify_binary, - github_server: &github_server_str, - trunk: (&ctx.trunk.0, &ctx.trunk.1), - author: &ctx.author, - branch_prefix: &ctx.branch_prefix, - user: &ctx.slug.owner, - repo: &ctx.slug.repo, - skip_rebase: opts.skip_rebase, - force_rebase: opts.force_rebase, - next_only: opts.next_only, - dry_run: opts.dry_run, - create_as_draft, - keep_pull_request_title_and_body, - only_update_existing_pulls: opts.only_update_existing_pulls, - revision_history, - no_verify: opts.no_verify, - github_native, - }, - ) - .await?; - // Dry-run buffers its plan and prints it here; a real - // push streams progress live from `push::run` as each - // step completes, so its transcript must not be - // re-printed. - if let mergify_stack::commands::push::Outcome::DryRun { log_lines, .. } = outcome { - for line in log_lines { - println!("{line}"); - } - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackList(opts) => { - let ctx = resolve_stack_context( - opts.token.as_deref(), - opts.author.as_deref(), - opts.repository.as_deref(), - opts.trunk.clone(), - opts.branch_prefix.clone(), - ) - .await?; - let stack = mergify_stack::commands::list::run( - &mergify_stack::commands::list::Options { - repo_dir: None, - client: &ctx.client, - user: &ctx.slug.owner, - repo: &ctx.slug.repo, - author: &ctx.author, - branch_prefix: &ctx.branch_prefix, - trunk: (&ctx.trunk.0, &ctx.trunk.1), - include_status: true, - }, - ) - .await?; - if opts.json { - let json = serde_json::to_string_pretty(&stack).map_err(|e| { - mergify_core::CliError::Generic(format!( - "serialize stack list: {e}" - )) - })?; - println!("{json}"); - } else { - render_stack_list_text(&stack, opts.verbose); - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackOpen(opts) => { - let ctx = resolve_stack_context( - opts.token.as_deref(), - opts.author.as_deref(), - opts.repository.as_deref(), - opts.trunk.clone(), - opts.branch_prefix.clone(), - ) - .await?; - // Picker only when a human is on both ends: stdin - // feeds the keystrokes, stderr is where dialoguer - // draws the list. stdout stays checked too so piped - // output (e.g. `| cat`) keeps the silent - // open-the-leaf default the Rust port shipped with. - let interactive = std::io::stdin().is_terminal() - && std::io::stdout().is_terminal() - && std::io::stderr().is_terminal(); - let picker: mergify_stack::commands::open::Selector<'_> = &|labels, default| { - mergify_tui::fuzzy_select("Select a PR to open", labels, default) - }; - let outcome = mergify_stack::commands::open::run( - &mergify_stack::commands::open::Options { - repo_dir: None, - client: &ctx.client, - user: &ctx.slug.owner, - repo: &ctx.slug.repo, - author: &ctx.author, - branch_prefix: &ctx.branch_prefix, - trunk: (&ctx.trunk.0, &ctx.trunk.1), - commit: opts.commit.as_deref(), - selector: interactive.then_some(picker), - }, - ) - .await?; - match outcome { - mergify_stack::commands::open::Outcome::Opened { - pull_number, - title, - pull_url, - } => { - println!("Opening PR #{pull_number}: {title}"); - println!(" {pull_url}"); - Ok(mergify_core::ExitCode::Success) - } - mergify_stack::commands::open::Outcome::EmptyStack => { - // Python exits STACK_NOT_FOUND (3) so callers - // can detect the empty stack via `$?`. - println!("No commits in stack"); - Ok(mergify_core::ExitCode::StackNotFound) - } - mergify_stack::commands::open::Outcome::Cancelled => { - // Esc in the picker: the user chose nothing — - // success, not failure (scripts rely on `$?`). - // Ctrl-C exits 130 in the picker's SIGINT - // handler and normally never reaches here. - Ok(mergify_core::ExitCode::Success) - } - } - } - NativeCommand::StackHooks(opts) => { - if opts.do_setup { - run_stack_setup(opts.force)?; - } else { - let status = mergify_stack::commands::setup::status(None)?; - render_hooks_status(&status); - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::StackSetup(opts) => { - if opts.check { - let status = mergify_stack::commands::setup::status(None)?; - render_hooks_status(&status); - } else { - run_stack_setup(opts.force)?; - } - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::SelfUpdate(opts) => { - self_update::run(&opts).await?; - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::InternalRebaseTodoRewrite(opts) => { - let action = match opts.action { - InternalRebaseAction::Edit => { - let sha = opts.sha.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action edit requires --sha" - .to_string(), - ) - })?; - mergify_stack::rebase_todo::Action::Edit { sha } - } - InternalRebaseAction::Drop => { - let raw = opts.shas.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action drop requires --shas" - .to_string(), - ) - })?; - let shas = raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - mergify_stack::rebase_todo::Action::Drop { shas } - } - InternalRebaseAction::Fixup => { - let raw = opts.shas.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action fixup requires --shas" - .to_string(), - ) - })?; - let shas = raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - mergify_stack::rebase_todo::Action::Fixup { shas } - } - InternalRebaseAction::Reword => { - let sha = opts.sha.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action reword requires --sha" - .to_string(), - ) - })?; - mergify_stack::rebase_todo::Action::Reword { sha } - } - InternalRebaseAction::Reorder => { - let raw = opts.shas.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action reorder requires --shas" - .to_string(), - ) - })?; - let ordered_shas = raw - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - mergify_stack::rebase_todo::Action::Reorder { ordered_shas } - } - InternalRebaseAction::Squash => { - let raw_shas = opts.shas.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action squash requires --shas" - .to_string(), - ) - })?; - let ordered_shas: Vec = raw_shas - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - let raw_fixup = opts.fixup_shas.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action squash requires --fixup-shas" - .to_string(), - ) - })?; - let fixup_shas: Vec = raw_fixup - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - mergify_stack::rebase_todo::Action::Squash { - ordered_shas, - fixup_shas, - exec_after_sha: opts.sha, - exec_command: opts.command, - } - } - InternalRebaseAction::ExecAfter => { - let sha = opts.sha.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action exec-after requires --sha" - .to_string(), - ) - })?; - let command = opts.command.ok_or_else(|| { - mergify_core::CliError::InvalidState( - "_internal rebase-todo-rewrite --action exec-after requires --command" - .to_string(), - ) - })?; - mergify_stack::rebase_todo::Action::ExecAfter { sha, command } - } - }; - let original = std::fs::read_to_string(&opts.todo_path).map_err(|e| { - mergify_core::CliError::Generic(format!( - "read rebase-todo at {}: {e}", - opts.todo_path.display() - )) - })?; - let rewritten = mergify_stack::rebase_todo::rewrite(&original, &action)?; - std::fs::write(&opts.todo_path, rewritten).map_err(|e| { - mergify_core::CliError::Generic(format!( - "write rebase-todo at {}: {e}", - opts.todo_path.display() - )) - })?; - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::InternalStackLocalCommits(opts) => { - // Run `git log` for the stack range, parse each - // commit's `Change-Id:` trailer, emit a JSON array - // on stdout. The Python `stack/changes.py` consumer - // deserializes it back into the existing `LocalChange` - // pipeline. A missing Change-Id propagates as - // `CliError::InvalidState` so Python sees the same - // exit code it would have raised inline. - let repo_dir = mergify_stack::local_commits::resolve_repo_dir(opts.repo_dir); - let commits = - mergify_stack::local_commits::read(&repo_dir, &opts.base, &opts.head)?; - let json = serde_json::to_string(&commits).map_err(|e| { - mergify_core::CliError::Generic(format!( - "serialize stack-local-commits output: {e}", - )) - })?; - println!("{json}"); - Ok(mergify_core::ExitCode::Success) - } - NativeCommand::InternalStackRemoteChanges(opts) => { - // Look up the PRs on the stack's live branches and - // group them by Change-Id, printed as a JSON array of - // `{change_id, pull}` records for whoever invoked this - // hidden command. - // - // Token comes from `--token` when supplied; otherwise - // `auth::resolve_github_token` reads `MERGIFY_TOKEN` / - // `GITHUB_TOKEN` / `gh auth token`, so a caller can - // pass it through the subprocess environment and keep - // it out of `ps`/process listings. - let token = - mergify_core::auth::resolve_github_token(opts.token.as_deref())?; - let client = mergify_stack::remote_changes::default_client( - opts.github_server, - &token, - )?; - let changes = mergify_stack::remote_changes::get_remote_changes( - &client, - &opts.user, - &opts.repo, - &opts.stack_prefix, - Some(opts.author.as_str()), - &[], - ) - .await?; - let json = serde_json::to_string(&changes).map_err(|e| { - mergify_core::CliError::Generic(format!( - "serialize stack-remote-changes output: {e}", - )) - })?; - println!("{json}"); - Ok(mergify_core::ExitCode::Success) - } - } - }); - - match result { - Ok(code) => ExitCode::from(code.as_u8()), - Err(err) => { - let code = err.exit_code(); - eprintln!("mergify: {err}"); - // Print any preserved cause chain (CliError::wrap / - // #[source]) so the underlying reason isn't lost. - let mut source = std::error::Error::source(&err); - while let Some(cause) = source { - eprintln!(" caused by: {cause}"); - source = cause.source(); - } - ExitCode::from(code.as_u8()) - } - } -} - -/// Mergify command-line interface. -/// -/// Drive Mergify from your terminal. Validate and simulate your -/// configuration, manage and inspect the merge queue, schedule merge -/// freezes, look at the tests tracked by CI Insights, and create and -/// maintain stacked pull requests. -/// -/// Most commands talk to the Mergify API and need a token. Set -/// `MERGIFY_TOKEN` (or `GITHUB_TOKEN`) to apply it to every command, -/// or pass `--token` to an individual command — there is no global -/// `--token` on `mergify` itself. Run `mergify --help` (or -/// the git-style `mergify help `) for detailed help and -/// options on any command. -#[derive(Parser)] -#[command(name = "mergify", version = VERSION)] -struct CliRoot { - /// Increase log verbosity: -v info, -vv debug, -vvv trace. Logs - /// go to stderr so stdout stays clean for piping. `RUST_LOG` - /// overrides this. - #[arg(short, long, global = true, action = clap::ArgAction::Count)] - verbose: u8, - - /// Shorthand for at least debug-level logging (like -vv). - #[arg(long, global = true)] - debug: bool, - - /// When to use color in terminal output. - #[arg(long, global = true, value_enum, default_value_t = ColorArg::Auto)] - color: ColorArg, - - #[command(subcommand)] - command: Subcommands, -} - -/// `--color` choice. Mirrors [`mergify_tui::ColorChoice`]; kept -/// separate so the clap derive lives in the binary and `mergify-tui` -/// stays clap-free. -#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)] -enum ColorArg { - #[default] - Auto, - Always, - Never, -} - -/// `true` when `name` is exported to something other than the empty -/// string, which is what means by "present". -/// -/// The rule itself lives in `mergify_core::env`, which is where it is -/// documented and tested; spelling it out a second time here is how -/// the color variables would drift away from every other variable -/// this CLI reads. -fn non_empty(name: &str) -> bool { - env::var_os_non_empty(name).is_some() -} - -/// Whether the log subscriber may emit ANSI on stderr. -/// -/// The same decision `Theme::detect` makes for stdout, against -/// stderr, because that is where the logs go. Without this -/// `--color never` and `NO_COLOR` styled stdout and left the `-vv` -/// stream on stderr full of escapes, which is the one place a user -/// redirects to a file. -fn ansi_on_stderr(color: mergify_tui::ColorChoice) -> bool { - match color { - mergify_tui::ColorChoice::Always => true, - mergify_tui::ColorChoice::Never => false, - mergify_tui::ColorChoice::Auto => std::io::stderr().is_terminal(), - } -} - -/// Map `--color` to the choice `mergify-tui` records, folding in -/// `NO_COLOR` / `FORCE_COLOR` / `CLICOLOR_FORCE`. -/// -/// An explicit `--color always|never` wins over all three, which is -/// why only the `Auto` arms consult them. `NO_COLOR` beats the two -/// force flags, per . -/// -/// This lives here rather than in `mergify-tui` so that crate never -/// reads the environment: it is the whole reason a consumer crate's -/// test binary used to touch the environment just by rendering a -/// themed line. -fn resolve_color_choice( - arg: ColorArg, - no_color: bool, - force_color: bool, -) -> mergify_tui::ColorChoice { - use mergify_tui::ColorChoice; - - match arg { - ColorArg::Always => ColorChoice::Always, - ColorArg::Never => ColorChoice::Never, - ColorArg::Auto if no_color => ColorChoice::Never, - ColorArg::Auto if force_color => ColorChoice::Always, - ColorArg::Auto => ColorChoice::Auto, - } -} - -#[derive(Subcommand)] -enum Subcommands { - /// Sign in to Mergify, and manage the stored credential. - /// - /// `mergify auth login` runs an OAuth device flow against the - /// Mergify API and stores the per-user token it mints, so the - /// commands that talk to Mergify need no token in your - /// environment. - Auth(AuthArgs), - /// Validate and simulate your Mergify configuration. - /// - /// Check your `.mergify.yml` against the schema before pushing it, - /// or simulate how Mergify's rules would evaluate against a given - /// pull request using your local configuration. - Config(ConfigArgs), - /// Run Mergify CI Insights commands from your pipeline. - /// - /// Helpers meant to run inside a CI job: upload test reports, - /// detect the build's git references, report the merge queue - /// batch the build belongs to, and compute the scopes impacted by - /// the changed files. - Ci(CiArgs), - /// Inspect the tests tracked by Mergify CI Insights. - /// - /// Look up a test's health and flakiness metrics, and manage the - /// quarantine that keeps known-flaky tests from failing the merge - /// queue. - Tests(TestsArgs), - /// Inspect and control the Mergify merge queue. - /// - /// Check the status of queued pull requests, inspect a single - /// pull request's queue state, and pause or resume merging for a - /// repository. - Queue(QueueArgs), - /// Browse the events Mergify recorded for the repository. - /// - /// List the activity log as a timeline — queue enters and leaves, - /// merges, commands, CI Insights, every event type — for the - /// whole repository or one pull request (`--pr`). The output - /// always states the time window it covers: the last 24 hours by - /// default, up to the 90 days the log retains (`--since 90d`), and - /// the newest page of events within it (`--limit`). - Events(EventsCliArgs), - /// Schedule and manage merge freezes. - /// - /// Create, list, update, and delete freezes that temporarily stop - /// the merge queue from merging — for release windows, incidents, - /// or code freezes. - Freeze(FreezeArgs), - /// Create and maintain stacked pull requests. - /// - /// Manage a stack of dependent branches and their pull requests: - /// create, push, sync, reorder, reword, squash, and check out - /// stacks built on top of your trunk. - Stack(StackArgs), - /// Update mergify to the latest release. - /// - /// Download the newest published binary, verify it against the - /// release `SHA256SUMS`, and atomically replace the running - /// executable. Uses the same artifact and checksum contract as the - /// `curl | sh` installer. - #[command(name = "self-update")] - SelfUpdate(SelfUpdateCli), - /// Print a shell completion script (e.g. `mergify completions zsh`). - Completions(CompletionsCli), - /// Internal helpers the Python side of the wheel calls during - /// the Python→Rust migration. Hidden from `--help` because it - /// is not part of the user-facing CLI; the wire format is not - /// stable and may change without notice. Do not depend on it - /// from anywhere outside the Python code shipped in this same - /// wheel. - #[command(name = "_internal", hide = true)] - Internal(InternalArgs), -} - -#[derive(clap::Args)] -struct StackArgs { - #[command(subcommand)] - command: StackSubcommand, -} - -/// Subcommands of `mergify stack`. clap sources each subcommand's -/// `about` from the variant doc comment (not the wrapped struct), so -/// the help text lives here. -#[derive(Subcommand)] -enum StackSubcommand { - /// Create a new stack branch. - /// - /// Create a branch that tracks your trunk and start a fresh stack on - /// top of it. The new branch is checked out by default; pass - /// `--no-checkout` to only create the ref and stay on the current - /// branch. - New(StackNewCli), - /// Attach a note explaining why a commit was amended. - /// - /// Record a note on a stack commit describing why it changed; the - /// note is surfaced when reviewing the stack. Edit it in your editor - /// or pass `-m`, add to an existing note with `--append`, or clear it - /// with `--remove`. Defaults to the commit at HEAD. - Note(StackNoteCli), - /// Edit a commit in the stack. - /// - /// Pause an interactive rebase on the target commit so you can amend - /// it, then resume. Omit the commit to start a fully interactive - /// rebase of the whole stack. - Edit(StackEditCli), - /// Drop commits from the stack. - /// - /// Remove one or more commits from the stack and rebase the commits - /// above them down. Each accepts a SHA prefix or a Change-Id prefix. - /// Use `--dry-run` to preview the resulting order first. - Drop(StackDropCli), - /// Fold commits into their parent. - /// - /// Squash each given commit into the commit below it, discarding the - /// folded commit's message (the parent's message is kept). Each - /// accepts a SHA prefix or a Change-Id prefix. Use `--dry-run` to - /// preview. - Fixup(StackFixupCli), - /// Change a commit's message. - /// - /// Rewrite the message of a commit in the stack. Pass `-m` to set the - /// new message inline, or omit it to edit the message in your editor. - /// Use `--dry-run` to preview. - Reword(StackRewordCli), - /// Reorder the stack's commits. - /// - /// Rebase the stack into the order you list. List every commit in the - /// stack — all of them must appear, in the new order. Each accepts a - /// SHA prefix or a Change-Id prefix. Use `--dry-run` to preview. - Reorder(StackReorderCli), - /// Move a commit within the stack. - /// - /// Move one commit to a new position in the stack: to the `first` or - /// `last` slot, or `before`/`after` another commit (passed as - /// TARGET). Use `--dry-run` to preview. - Move(StackMoveCli), - /// Squash commits into a target commit. - /// - /// Fold one or more source commits into a target commit, reordering - /// them adjacent to it first. Use the form `SRC... into TARGET`. Pass - /// `-m` to set the combined message; otherwise the target's message - /// is kept. Use `--dry-run` to preview. - Squash(StackSquashCli), - /// Check out a pull request stack from GitHub. - /// - /// Identify the stack by its remote branch name, or by the URL of any - /// pull request in it — the middle of the stack works as well as the - /// tip. Creates a local branch tracking the stack's leaf, so you can - /// continue working on a stack created elsewhere (a teammate's, or - /// your own from another machine). - Checkout(StackCheckoutCli), - /// Sync the stack with its trunk. - /// - /// Fetch the latest trunk, drop commits whose pull request has - /// already merged, and rebase the remaining stack on top. Use - /// `--dry-run` to preview. - Sync(StackSyncCli), - /// Push the stack and create or update its pull requests. - /// - /// Walk the local stack, optionally rebase it on trunk, push each - /// commit to its own branch, and create or update the matching pull - /// request and its Depends-On chain on GitHub. Use `--dry-run` to - /// preview the plan and the rebase decision without touching - /// anything. - Push(StackPushCli), - /// List the stack's commits and their pull requests. - /// - /// Show each commit in the current stack alongside its pull request, - /// CI, and review state. Pass `--verbose` for per-check and - /// per-reviewer detail, or `--json` for machine-readable output. - List(StackListCli), - /// Open a stack commit's pull request in the browser. - /// - /// Open the GitHub pull request for a commit in the stack in your - /// default browser. Defaults to the commit at HEAD. - Open(StackOpenCli), - /// Show or install the stack git hooks. - /// - /// Report the status of the git hooks that keep `Change-Id` trailers - /// on your commits (stacks rely on them to track commits across - /// rebases). Pass `--setup` to install or upgrade them. - Hooks(StackHooksCli), - /// Install the stack git hooks. - /// - /// Install or upgrade the git hooks that add `Change-Id` trailers to - /// your commits. This is an alias for `stack hooks --setup`; pass - /// `--check` to report status instead of installing. - Setup(StackSetupCli), -} - -#[derive(clap::Args)] -struct InternalArgs { - #[command(subcommand)] - command: InternalSubcommand, -} - -#[derive(Subcommand)] -enum InternalSubcommand { - /// Walk the local stack commits in `..` and print - /// a JSON array of `{commit_sha, title, message, change_id, slug}`. - /// Used by the Python side of `mergify stack ` during - /// migration to centralise the `git log` + `Change-Id:` - /// extraction. Not a stable user-facing surface. - #[command(name = "stack-local-commits")] - StackLocalCommits(InternalStackLocalCommitsArgs), - /// Look up the open + merged PRs on a stack's live branches - /// and group them by `Change-Id`. A debugging aid: it has no - /// local stack, so a merged PR whose branch was deleted is not - /// found. Not a stable user-facing surface. - #[command(name = "stack-remote-changes")] - StackRemoteChanges(InternalStackRemoteChangesArgs), - /// Self-invocation target for the rebase-family stack - /// subcommands. `mergify stack ` sets - /// `GIT_SEQUENCE_EDITOR` to a command line ending in this - /// subcommand before spawning `git rebase -i`; git invokes - /// it on the freshly-written rebase-todo file. Not a stable - /// user-facing surface. - #[command(name = "rebase-todo-rewrite")] - RebaseTodoRewrite(InternalRebaseTodoRewriteArgs), - /// Emit the machine-readable CLI schema (the JSON the docs site - /// renders into the command reference) to stdout. Walks the clap - /// command tree so every description and flag is sourced from the - /// code, never hand-maintained. Not a stable user-facing surface. - #[command(name = "dump-cli-schema")] - DumpCliSchema, - /// Render the roff man page to stdout, for packaging to install - /// into `man/`. Not a stable user-facing surface. - #[command(name = "man")] - Man, -} - -#[derive(clap::Args)] -struct InternalRebaseTodoRewriteArgs { - /// Transformation to apply. New variants land with the - /// respective port slices (today: `edit`, `drop`, `fixup`, - /// `reword`, `exec-after`). - #[arg(long, value_enum)] - action: InternalRebaseAction, - /// Target SHA — required for `edit`, `reword`, `exec-after`. - #[arg(long)] - sha: Option, - /// Comma-separated SHAs — required for `drop`, `fixup`, - /// `reorder`, `squash` (full new order). - #[arg(long)] - shas: Option, - /// Comma-separated SHAs to fold as fixup — used by `squash`. - #[arg(long = "fixup-shas")] - fixup_shas: Option, - /// Shell command to inject as an `exec` line — required for - /// `exec-after`, optional for `squash`. - #[arg(long)] - command: Option, - /// Path to the rebase-todo file git wrote; positional so it - /// catches whatever git's `sh -c "$EDITOR \"$@\"" sh ` - /// hands us. - todo_path: PathBuf, -} - -#[derive(clap::Args)] -struct StackNewCli { - /// Name of the new branch. - name: String, - - /// Base branch to fork from, formatted as `REMOTE/BRANCH` - /// (e.g. `origin/main`). When omitted, the trunk is resolved - /// from the current branch's tracking info or - /// `refs/remotes/origin/HEAD`. - #[arg(long, short = 'b', value_parser = parse_remote_branch)] - base: Option<(String, String)>, - - /// Checkout the new branch after creation. This is the default. - /// Pass `--no-checkout` to keep the current branch checked out. - #[arg( - long = "checkout", - action = clap::ArgAction::SetTrue, - conflicts_with = "no_checkout", - )] - #[allow(dead_code)] // default-on; only consumed for `conflicts_with` - checkout: bool, - - /// Leave the current branch checked out and just create the - /// new branch ref. - #[arg(long = "no-checkout", action = clap::ArgAction::SetTrue)] - no_checkout: bool, -} - -impl From for StackNewOpts { - fn from(cli: StackNewCli) -> Self { - Self { - name: cli.name, - base: cli.base, - // Default is checkout-on; `--no-checkout` is the only - // way to flip it. `--checkout` is accepted for parity - // with the Python click flag pair but is a no-op since - // it matches the default. - checkout: !cli.no_checkout, - } - } -} - -#[derive(clap::Args)] -struct StackEditCli { - /// Commit to pause the rebase on. Accepts a SHA prefix or a - /// Change-Id prefix; omit for a fully interactive rebase. - commit: Option, -} - -impl From for StackEditOpts { - fn from(cli: StackEditCli) -> Self { - Self { - commit_prefix: cli.commit, - } - } -} - -#[derive(clap::Args)] -struct StackDropCli { - /// Commits to drop. Each accepts a SHA prefix or a Change-Id - /// prefix. - #[arg(required = true)] - commits: Vec, - - /// Show the plan without dropping. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -impl From for StackDropOpts { - fn from(cli: StackDropCli) -> Self { - Self { - commit_prefixes: cli.commits, - dry_run: cli.dry_run, - } - } -} - -#[derive(clap::Args)] -struct StackFixupCli { - /// Commits to fold into their parent. - #[arg(required = true)] - commits: Vec, - - /// Show the plan without rebasing. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -impl From for StackFixupOpts { - fn from(cli: StackFixupCli) -> Self { - Self { - commit_prefixes: cli.commits, - dry_run: cli.dry_run, - } - } -} - -#[derive(clap::Args)] -struct StackRewordCli { - /// Commit to reword. Accepts a SHA prefix or a Change-Id prefix. - commit: String, - - /// New message. When omitted, `git rebase -i` pauses at the - /// target and opens `$GIT_EDITOR`. - #[arg(short = 'm', long = "message")] - message: Option, - - /// Show the plan without rebasing. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -impl From for StackRewordOpts { - fn from(cli: StackRewordCli) -> Self { - Self { - commit_prefix: cli.commit, - message: cli.message, - dry_run: cli.dry_run, - } - } -} - -#[derive(clap::Args)] -struct StackReorderCli { - /// Every commit in the stack, in the order you want them rebased - /// into. All stack commits must be listed. - #[arg(required = true)] - commits: Vec, - - /// Show the plan without reordering. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -impl From for StackReorderOpts { - fn from(cli: StackReorderCli) -> Self { - Self { - commit_prefixes: cli.commits, - dry_run: cli.dry_run, - } - } -} - -#[derive(clap::Args)] -struct StackMoveCli { - /// Commit to move. Accepts a SHA prefix or a Change-Id prefix. - commit: String, - - /// Where to put it: `first`, `last`, `before`, `after`. - #[arg(value_enum)] - position: StackMovePosition, - - /// Required when `position` is `before` or `after`. - target: Option, - - /// Show the plan without moving. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -impl From for StackMoveOpts { - fn from(cli: StackMoveCli) -> Self { - Self { - commit_prefix: cli.commit, - position: cli.position, - target_prefix: cli.target, - dry_run: cli.dry_run, - } - } -} - -// The `... into ` shape doesn't fit clap's positional -// model directly, so we accept a flat `Vec` and split on the -// literal `into` keyword inside [`StackSquashOpts::try_from`]. -#[derive(clap::Args)] -struct StackSquashCli { - /// `SRC1 SRC2 ... into TARGET` — must contain exactly one - /// `into` token; everything before is a source, the single - /// token after is the target. - #[arg(required = true, num_args = 3..)] - tokens: Vec, - - /// Final commit message (required to rename; otherwise the - /// target's message is kept). - #[arg(short = 'm', long = "message")] - message: Option, - - /// Show the plan without rebasing. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, -} - -#[derive(clap::Args)] -struct StackCheckoutCli { - /// Stack to check out: its remote branch name, or the URL of - /// any pull request in it. - target: String, - - /// `owner/repo`. Falls back to the URL of `--trunk`'s remote. - /// Ignored when the target is a pull request URL, which - /// carries its own. - #[arg(long = "repository", alias = "repo")] - repository: Option, - - /// Local branch name. Defaults to the stack branch minus your - /// own stack branch prefix, or to its last segment when the - /// stack isn't under that prefix. - #[arg(long)] - branch: Option, - - /// Show the plan without checking out. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, - - /// Target trunk as `REMOTE/BRANCH`. Defaults to the resolved - /// trunk for the current branch. - #[arg(short = 't', long = "trunk", value_parser = parse_remote_branch)] - trunk: Option<(String, String)>, - - /// GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` - /// / `gh auth token`). - #[arg(long)] - token: Option, -} - -impl From for StackCheckoutOpts { - fn from(cli: StackCheckoutCli) -> Self { - Self { - target: cli.target, - repository: cli.repository, - branch: cli.branch, - dry_run: cli.dry_run, - trunk: cli.trunk, - token: cli.token, - } - } -} - -#[derive(clap::Args)] -struct StackSyncCli { - /// Author of the stack. Defaults to the token's user. - #[arg(long)] - author: Option, - - /// `owner/repo`. Falls back to the URL of `--trunk`'s remote. - #[arg(long = "repository", alias = "repo")] - repository: Option, - - /// Override the stack branch prefix. - #[arg(long = "branch-prefix")] - branch_prefix: Option, - - /// Show the plan without changing anything. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, - - /// Target trunk as `REMOTE/BRANCH`. Defaults to the resolved - /// trunk for the current branch. - #[arg(short = 't', long = "trunk", value_parser = parse_remote_branch)] - trunk: Option<(String, String)>, - - /// GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` - /// / `gh auth token`). - #[arg(long)] - token: Option, -} - -impl From for StackSyncOpts { - fn from(cli: StackSyncCli) -> Self { - Self { - author: cli.author, - repository: cli.repository, - branch_prefix: cli.branch_prefix, - dry_run: cli.dry_run, - trunk: cli.trunk, - token: cli.token, - } - } -} - -#[derive(clap::Args)] -#[allow( - clippy::struct_excessive_bools, - reason = "mirrors the Python CLI's flag surface 1:1" -)] -struct StackPushCli { - /// Author of the stack. Defaults to the token's user. - #[arg(long)] - author: Option, - - /// `owner/repo`. Falls back to the URL of `--trunk`'s remote. - #[arg(long = "repository", alias = "repo")] - repository: Option, - - /// Override the stack branch prefix. - #[arg(long = "branch-prefix")] - branch_prefix: Option, - - /// Target trunk as `REMOTE/BRANCH`. Defaults to the resolved - /// trunk for the current branch. - #[arg(short = 't', long = "trunk", value_parser = parse_remote_branch)] - trunk: Option<(String, String)>, - - /// GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` - /// / `gh auth token`). - #[arg(long)] - token: Option, - - /// Skip the rebase step. By default `stack push` rebases on - /// trunk before pushing when there are no approvals to - /// dismiss. - #[arg(short = 'R', long = "skip-rebase", action = clap::ArgAction::SetTrue)] - skip_rebase: bool, - - /// Force the rebase even when PRs are approved (the rebase - /// will dismiss the reviews). Mutually exclusive with - /// `--skip-rebase`. - #[arg( - long = "force-rebase", - action = clap::ArgAction::SetTrue, - conflicts_with = "skip_rebase" - )] - force_rebase: bool, - - /// Only push the bottom commit of the stack. - #[arg(short = 'x', long = "next-only", action = clap::ArgAction::SetTrue)] - next_only: bool, - - /// Dry-run: render the plan + the rebase decision and exit. - #[arg(short = 'n', long = "dry-run", action = clap::ArgAction::SetTrue)] - dry_run: bool, - - /// Open new PRs as drafts. Default falls back to git config - /// `mergify-cli.stack-create-as-draft` (`false` when unset). - #[arg( - short = 'd', - long = "draft", - num_args = 0, - default_missing_value = "true" - )] - create_as_draft: Option, - - /// Don't rewrite the PR title + body from the commit - /// message; only update the rendered Depends-On chain in - /// the body. Default falls back to git config - /// `mergify-cli.stack-keep-pr-title-body` (`false` when unset). - #[arg( - short = 'k', - long = "keep-pull-request-title-and-body", - num_args = 0, - default_missing_value = "true" - )] - keep_pull_request_title_and_body: Option, - - /// Don't create new PRs; surface would-be-created ones in - /// the plan instead. - #[arg( - short = 'u', - long = "only-update-existing-pulls", - action = clap::ArgAction::SetTrue - )] - only_update_existing_pulls: bool, - - /// Suppress the revision-history sticky comment update. - /// Default falls back to git config - /// `mergify-cli.stack-revision-history` (`true` when unset). - #[arg( - long = "no-revision-history", - num_args = 0, - default_missing_value = "false" - )] - revision_history: Option, - - /// Pass `--no-verify` to `git push` (skips local pre-push - /// hooks). - #[arg(long = "no-verify", action = clap::ArgAction::SetTrue)] - no_verify: bool, - - /// Don't register the stack with GitHub's native Stacks API. - /// By default, `mergify stack push` registers it, so GitHub - /// shows it as a stack and holds the ordering (the PR - /// descriptions then carry no `Depends-On:` header). Where the - /// API isn't available, registration is skipped and the push - /// reports `not registered on GitHub`. Default falls back to - /// git config `mergify-cli.stack-github-native` (`true` when - /// unset). - #[arg( - long = "no-github-native", - num_args = 0, - default_missing_value = "false" - )] - github_native: Option, -} - -impl From for StackPushOpts { - fn from(cli: StackPushCli) -> Self { - Self { - author: cli.author, - repository: cli.repository, - branch_prefix: cli.branch_prefix, - trunk: cli.trunk, - token: cli.token, - skip_rebase: cli.skip_rebase, - force_rebase: cli.force_rebase, - next_only: cli.next_only, - dry_run: cli.dry_run, - create_as_draft: cli.create_as_draft, - keep_pull_request_title_and_body: cli.keep_pull_request_title_and_body, - only_update_existing_pulls: cli.only_update_existing_pulls, - revision_history: cli.revision_history, - no_verify: cli.no_verify, - github_native: cli.github_native, - } - } -} - -#[derive(clap::Args)] -struct StackListCli { - /// Author of the stack. Defaults to the token's user. - #[arg(long)] - author: Option, - - /// `owner/repo`. Falls back to the URL of `--trunk`'s remote. - #[arg(long = "repository", alias = "repo")] - repository: Option, - - /// Override the stack branch prefix. - #[arg(long = "branch-prefix")] - branch_prefix: Option, - - /// Target trunk as `REMOTE/BRANCH`. Defaults to the resolved - /// trunk for the current branch. - #[arg(short = 't', long = "trunk", value_parser = parse_remote_branch)] - trunk: Option<(String, String)>, - - /// GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` - /// / `gh auth token`). - #[arg(long)] - token: Option, - - /// Emit machine-readable JSON. - #[arg(long, action = clap::ArgAction::SetTrue)] - json: bool, -} - -impl From for StackListOpts { - fn from(cli: StackListCli) -> Self { - Self { - author: cli.author, - repository: cli.repository, - branch_prefix: cli.branch_prefix, - trunk: cli.trunk, - token: cli.token, - json: cli.json, - // Driven by the global `-v`/`--verbose` flag, applied in dispatch. - verbose: false, - } - } -} - -#[derive(clap::Args)] -struct StackOpenCli { - /// Commit whose pull request to open. Accepts a SHA prefix or a - /// Change-Id prefix. When omitted: on a terminal, pick - /// interactively from the stack (type to fuzzy-filter, HEAD - /// preselected); otherwise defaults to HEAD. - commit: Option, - - /// Author of the stack. Defaults to the token's user. - #[arg(long)] - author: Option, - - /// `owner/repo`. Falls back to the URL of `--trunk`'s remote. - #[arg(long = "repository", alias = "repo")] - repository: Option, - - /// Override the stack branch prefix. - #[arg(long = "branch-prefix")] - branch_prefix: Option, - - /// Target trunk as `REMOTE/BRANCH`. Defaults to the resolved - /// trunk for the current branch. - #[arg(short = 't', long = "trunk", value_parser = parse_remote_branch)] - trunk: Option<(String, String)>, - - /// GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` - /// / `gh auth token`). - #[arg(long)] - token: Option, -} - -impl From for StackOpenOpts { - fn from(cli: StackOpenCli) -> Self { - Self { - commit: cli.commit, - author: cli.author, - repository: cli.repository, - branch_prefix: cli.branch_prefix, - trunk: cli.trunk, - token: cli.token, - } - } -} - -#[derive(clap::Args)] -struct StackHooksCli { - /// Install or upgrade hooks. - #[arg(long = "setup", action = clap::ArgAction::SetTrue)] - do_setup: bool, - - /// Force reinstall wrappers (use with --setup). - #[arg(short = 'f', long = "force", action = clap::ArgAction::SetTrue)] - force: bool, -} - -impl From for StackHooksOpts { - fn from(cli: StackHooksCli) -> Self { - Self { - do_setup: cli.do_setup, - force: cli.force, - } - } -} - -#[derive(clap::Args)] -struct StackSetupCli { - /// Force reinstall of hook wrappers, even if user modified them. - #[arg(short = 'f', long = "force", action = clap::ArgAction::SetTrue)] - force: bool, - - /// Check status only (use 'stack hooks' instead). - #[arg(long = "check", action = clap::ArgAction::SetTrue)] - check: bool, -} - -impl From for StackSetupOpts { - fn from(cli: StackSetupCli) -> Self { - Self { - force: cli.force, - check: cli.check, - } - } -} - -/// Update mergify to the latest release. -/// -/// Download the newest published binary, verify it against the -/// release `SHA256SUMS`, and atomically replace the running -/// executable. Pass `--check` to only compare versions, or `--force` -/// to reinstall even when already up to date. -#[derive(Parser)] -#[command(name = "self-update")] -struct SelfUpdateCli { - /// Re-download and re-install even when the running binary - /// already matches the latest release tag. Useful for - /// repairing a corrupted install without bumping the version. - #[arg(long, action = clap::ArgAction::SetTrue)] - force: bool, - - /// Print the current and latest release tags and exit without - /// touching the binary. - #[arg(long, action = clap::ArgAction::SetTrue)] - check: bool, -} - -impl From for self_update::Options { - fn from(cli: SelfUpdateCli) -> Self { - Self { - force: cli.force, - check_only: cli.check, - } - } -} - -#[derive(clap::Args)] -struct CompletionsCli { - /// Shell to generate a completion script for. - #[arg(value_enum)] - shell: clap_complete::Shell, -} - -impl TryFrom for StackSquashOpts { - type Error = String; - - fn try_from(cli: StackSquashCli) -> Result { - let into_positions: Vec = cli - .tokens - .iter() - .enumerate() - .filter_map(|(i, t)| (t == "into").then_some(i)) - .collect(); - if into_positions.len() != 1 { - return Err( - "squash requires exactly one 'into' keyword: SRC... into TARGET".to_string(), - ); - } - let idx = into_positions[0]; - let srcs: Vec = cli.tokens[..idx].to_vec(); - let after = &cli.tokens[idx + 1..]; - if srcs.is_empty() { - return Err("at least one source commit required before 'into'".to_string()); - } - if after.len() != 1 { - return Err("exactly one target commit required after 'into'".to_string()); - } - Ok(Self { - src_prefixes: srcs, - target_prefix: after[0].clone(), - message: cli.message, - dry_run: cli.dry_run, - }) - } -} - -#[derive(clap::Args)] -struct StackNoteCli { - /// Target commit. Accepts a SHA prefix, a ref (`HEAD~1`, - /// branch name, …), or a Change-Id prefix (resolved against - /// the stack walk). Defaults to HEAD. - commit: Option, - - /// Note message. If omitted, opens `$GIT_EDITOR` / - /// `$VISUAL` / `$EDITOR` / `vi` on a tempfile. - #[arg(short = 'm', long = "message")] - message: Option, - - /// Append to an existing note instead of replacing. - #[arg(long = "append", action = clap::ArgAction::SetTrue)] - append: bool, - - /// Remove the note on the target commit. Mutually exclusive - /// with `--message` and `--append`. - #[arg( - long = "remove", - action = clap::ArgAction::SetTrue, - conflicts_with_all = ["message", "append"], - )] - remove: bool, -} - -impl From for StackNoteOpts { - fn from(cli: StackNoteCli) -> Self { - Self { - commit: cli.commit, - message: cli.message, - append: cli.append, - remove: cli.remove, - } - } -} - -/// Parse a `REMOTE/BRANCH` argument into its two parts. Matches -/// the Python `trunk_type` click callback in -/// `mergify_cli/stack/cli.py`: split on the first `/`, so branch -/// names containing `/` (e.g. `release/2026.06`) survive intact; -/// the error message is kept verbatim so users see the same text -/// regardless of which `stack` subcommand is parsing. -fn parse_remote_branch(value: &str) -> Result<(String, String), String> { - value - .split_once('/') - .map(|(r, b)| (r.to_string(), b.to_string())) - .ok_or_else(|| "Trunk is invalid. It must be origin/branch-name".to_string()) -} - -#[derive(clap::Args)] -struct InternalStackLocalCommitsArgs { - /// Base revision — anything `git` accepts (typically a merge- - /// base SHA). The range is exclusive of this commit. - #[arg(long)] - base: String, - /// Head revision — typically the local stack branch name. - /// The range is inclusive of this commit. - #[arg(long)] - head: String, - /// Repository working tree to run `git` in. Defaults to the - /// process CWD. - #[arg(long = "repo-dir", value_name = "DIR")] - repo_dir: Option, -} - -#[derive(clap::Args)] -struct InternalStackRemoteChangesArgs { - /// GitHub API base URL (e.g. `https://api.github.com`). - #[arg(long = "github-server")] - github_server: url::Url, - /// Bearer token. Optional — when omitted the binary falls - /// back to `mergify_core::auth::resolve_github_token` (which reads - /// `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`). Prefer - /// setting `MERGIFY_TOKEN` in the subprocess environment over - /// passing `--token`, so the value doesn't surface in - /// `ps`/process listings. - #[arg(long)] - token: Option, - /// Repository owner. - #[arg(long)] - user: String, - /// Repository name. - #[arg(long)] - repo: String, - /// Stack branch prefix (e.g. `stack/main` — the branches - /// looked up are `/--`). - #[arg(long = "stack-prefix")] - stack_prefix: String, - /// PR author to filter on. Keeps only PRs the current user - /// opened — `mergify stack` only manages its own. - #[arg(long)] - author: String, -} - -/// `--config-file`'s long help. Same reason as -/// [`SCOPES_CONFIG_HELP`]: the list of searched paths belongs to the -/// resolver, not to a doc comment that can fall behind it. -static CONFIG_FILE_HELP: std::sync::LazyLock = std::sync::LazyLock::new(|| { - format!( - "Path to the Mergify configuration file.\n\nWhen omitted, the first of these that \ - exists is used: {}. A repository carrying more than one gets a warning on stderr \ - naming the file in use and the ones ignored.", - mergify_config::paths::DEFAULT_CONFIG_PATHS.join(", "), - ) -}); - -#[derive(clap::Args)] -struct ConfigArgs { - /// Path to the Mergify configuration file (auto-detected if not - /// provided). - #[arg( - long = "config-file", - short = 'f', - global = true, - long_help = CONFIG_FILE_HELP.as_str(), - )] - config_file: Option, - - #[command(subcommand)] - command: ConfigSubcommand, -} - -#[derive(Subcommand)] -enum ConfigSubcommand { - /// Validate the Mergify configuration file against the schema. - /// - /// Check that your configuration file parses and conforms to the - /// Mergify schema, reporting the first error with its location. - /// Run it locally or in CI to catch mistakes before they reach - /// the default branch. The file is auto-detected unless you pass - /// `--config-file`. - Validate(ValidateArgs), - /// Simulate Mergify actions on a pull request using the local - /// configuration. - /// - /// Evaluate your local configuration against a real pull request - /// and report which rules match and what actions Mergify would - /// take — without changing anything on GitHub. Useful to test a - /// configuration change before pushing it. - Simulate(SimulateCliArgs), -} - -#[derive(clap::Args)] -struct ValidateArgs {} - -#[derive(clap::Args)] -struct SimulateCliArgs { - /// Pull request URL (e.g. ). - #[arg(value_name = "PULL_REQUEST_URL", value_parser = mergify_core::pull_request::parse_pr_url)] - pull_request: PullRequestRef, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, -} - -#[derive(clap::Args)] -struct CiArgs { - #[command(subcommand)] - command: CiSubcommand, -} - -#[derive(Subcommand)] -// CiSubcommand variant docstrings double as `mergify ci --help` -// entries — clap renders them verbatim, so backticks would surface -// as literal characters to the user. Suppress doc_markdown here -// so the help text reads naturally. -#[allow(clippy::doc_markdown)] -enum CiSubcommand { - /// Send scopes tied to a pull request to Mergify. - /// - /// Upload the scopes affected by a pull request so CI Insights can - /// attribute test results to them. Reads scopes from repeated - /// --scope flags or from a file produced by "mergify ci scopes - /// --write". Exits 0 without doing anything when no pull request - /// can be determined. - #[command(name = "scopes-send")] - ScopesSend(ScopesSendCliArgs), - /// Print the base/head git references for the current build. - /// - /// Detect and print the base and head git references for the build - /// from the CI environment, as plain text, eval-friendly shell - /// lines, or JSON. Useful for wiring later steps to the same refs - /// Mergify computed. - #[command(name = "git-refs")] - GitRefs(GitRefsCliArgs), - /// Print the current build's merge queue batch metadata (from the - /// Mergify git note). - /// - /// Read the Mergify git note attached to the current HEAD and print - /// the merge queue batch the build belongs to. Needs only plain - /// git and no token, so it works in any CI runner. - #[command(name = "queue-info")] - QueueInfo(QueueInfoCliArgs), - /// Give the list of scopes impacted by changed files. - /// - /// Compute the configured scopes impacted by the files changed - /// between two git references, using your Mergify configuration. - /// On a merge queue draft pull request, return instead the scopes - /// the merge queue decided for the batch, read from its git note, - /// plus the merge queue scope. Print them, or write them to a file - /// with --write for a later "mergify ci scopes-send". - Scopes(ScopesCliArgs), - /// Upload JUnit XML reports and ignore failed tests with - /// Mergify's CI Insights Quarantine. - /// - /// Parse one or more JUnit XML reports, upload the results to CI - /// Insights, and reconcile them against the quarantine so - /// known-flaky tests don't fail the build. Accepts file paths or - /// glob patterns; pass the runner's exit code with --test-exit-code - /// to detect silent failures. - #[command(name = "junit-process")] - JunitProcess(JunitProcessCliArgs), - /// Upload JUnit XML reports (deprecated: use junit-process). - /// - /// Deprecated alias for junit-process, kept for backward - /// compatibility. It runs the same processing and prints a - /// deprecation warning; use junit-process instead. - #[command(name = "junit-upload")] - JunitUpload(JunitProcessCliArgs), -} - -#[derive(clap::Args)] -struct GitRefsCliArgs { - /// Output format: `text` (default), `shell` for eval-friendly - /// `MERGIFY_GIT_REFS_*` lines, or `json` for a single JSON - /// object. - #[arg( - long = "format", - default_value = "text", - value_parser = mergify_ci::git_refs::Format::parse, - )] - format: GitRefsFormat, -} - -/// `queue-info` reads the `refs/notes/mergify/` git note -/// Mergify writes for the current `HEAD`, so it takes no arguments and -/// needs no GitHub token — plain git in any CI. -#[derive(clap::Args)] -struct QueueInfoCliArgs {} - -/// `--config`'s long help, built from the resolver's own search -/// list so the two cannot drift apart. clap wants a `&'static str`; -/// a `LazyLock` is what turns a runtime `join` into one. -static SCOPES_CONFIG_HELP: std::sync::LazyLock = std::sync::LazyLock::new(|| { - format!( - "Path to YAML config file.\n\nFalls back to the MERGIFY_CONFIG_PATH environment \ - variable, then auto-detects the first of these that exists: {}.", - mergify_config::paths::DEFAULT_CONFIG_PATHS.join(", "), - ) -}); - -#[derive(clap::Args)] -struct ScopesCliArgs { - /// Path to YAML config file (auto-detected when omitted). - // - // The env var lookup is intentionally *not* delegated to - // clap's `env = ...` attribute: callers (notably the - // `gha-mergify-ci` action) set `MERGIFY_CONFIG_PATH=""` to - // mean "auto-detect", and clap's `env` parser treats an - // empty env value as a present-but-empty flag value, which - // fails the parse with `a value is required for '--config'`. - // The env var is consulted inside - // `mergify_ci::scopes_detect::resolve_config_path` instead, - // where empty correctly falls through to auto-detect. The - // matching regression tests are - // `no_argument_takes_its_value_from_the_environment` (asks the - // built `Command` whether *any* argument carries an `env` - // attribute, which covers this one) and - // `resolve_config_path_treats_empty_env_var_as_unset` - // (lower-level resolver). - #[arg(long, long_help = SCOPES_CONFIG_HELP.as_str())] - config: Option, - - /// Base git reference to use to look for changed files. - #[arg(long)] - base: Option, - - /// Head git reference to use to look for changed files. - #[arg(long)] - head: Option, - - /// Write the detected scopes to a file (JSON, consumed by - /// `ci scopes-send --scopes-json`). - #[arg(long = "write", short = 'w')] - write: Option, -} - -#[derive(clap::Args)] -struct ScopesSendCliArgs { - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg(long, short = 'r')] - repository: Option, - - /// Pull request number. When omitted, it's detected from the CI - /// environment: under GitHub Actions from the event payload - /// (``.pull_request.number`` in ``GITHUB_EVENT_PATH``), under - /// Buildkite from ``BUILDKITE_PULL_REQUEST``. When none can be - /// detected the command prints a skip message and exits 0. - #[arg(long = "pull-request", short = 'p')] - pull_request: Option, - - /// Mergify application key. Falls back to ``MERGIFY_TOKEN``, then - /// ``GITHUB_TOKEN`` and ``gh auth token`` — both deprecated for - /// the Mergify API. The CI endpoints require an application key, - /// so the credential ``mergify auth login`` stores is not used - /// here. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Scope to upload (repeatable). - #[arg(long = "scope", short = 's')] - scope: Vec, - - /// JSON file containing scopes to upload (output of - /// ``mergify ci scopes --write``). - #[arg(long = "scopes-json")] - scopes_json: Option, - - /// Plain-text file with one scope per line. - #[arg(long = "scopes-file")] - scopes_file: Option, - - /// Deprecated alias for ``--scopes-json``. - #[arg(long = "file", short = 'f', hide = true)] - file_deprecated: Option, - - /// Declare that the pull request impacts every scope. The merge - /// queue then treats it as a barrier: it is never batched or run - /// in parallel with other pull requests. The concrete scopes are - /// still sent alongside the flag. - #[arg(long = "all")] - all: bool, - - /// Head SHA the scopes were computed for, as 40 hexadecimal - /// characters, so a result computed for an older head is not - /// taken for the current one. When omitted it is detected from - /// the CI environment: under GitHub Actions from the event - /// payload (``.pull_request.head.sha`` in ``GITHUB_EVENT_PATH``), - /// under Buildkite from ``BUILDKITE_COMMIT``. Pass the pull - /// request's head, not the revision the job checked out — on a - /// ``pull_request`` event those differ. - #[arg(long = "head-sha", value_parser = mergify_ci::detector::parse_head_sha)] - head_sha: Option, -} - -#[derive(clap::Args)] -// Help text is rendered verbatim by clap; backticks would surface -// as literal characters to the user. Suppress the doc_markdown -// lint just for this struct so the docstrings read naturally in -// `--help` output. -#[allow(clippy::doc_markdown)] -struct JunitProcessCliArgs { - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default (`https://api.mergify.com`). - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// CI Issues application key. Falls back to ``MERGIFY_TOKEN``. - #[arg(long, short = 't')] - token: Option, - - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg(long, short = 'r')] - repository: Option, - - /// Test framework label (e.g. `pytest`). Optional; passed as a - /// span attribute. - #[arg(long = "test-framework")] - test_framework: Option, - - /// Test language label (e.g. `python`). Optional; passed as a - /// span attribute. - #[arg(long = "test-language")] - test_language: Option, - - /// Branch the quarantine API should look up tests on. Defaults - /// to the PR base branch, or the head branch as a fallback. - #[arg(long = "tests-target-branch", short = 'b')] - tests_target_branch: Option, - - /// Exit code of the test runner. When this is non-zero but no - /// failures appear in the JUnit report, the run is flagged - /// as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``. - // - // The env-var fallback is intentionally NOT delegated to - // clap's `env = ...` attribute: callers (notably the - // `gha-mergify-ci` action) set `MERGIFY_TEST_EXIT_CODE=""` - // when no exit code is available, meaning "no value". Clap - // would try to parse the empty string as `i32` and exit - // parsing with `invalid value '' for '--test-exit-code': - // cannot parse integer from empty string`. The env var is - // consulted inside `junit_process::command::resolve_test_exit_code` - // instead, where empty correctly maps to `None`. Same trap - // hit the `--config` flag — see `ScopesCliArgs::config`. - #[arg(long = "test-exit-code", short = 'e')] - test_exit_code: Option, - - /// JUnit XML files or glob patterns (e.g. - /// `reports/**/*.xml`). At least one path or pattern is required. - #[arg(value_name = "FILE", required = true, num_args = 1..)] - files: Vec, -} - -#[derive(clap::Args)] -struct TestsArgs { - #[command(subcommand)] - command: TestsSubcommand, -} - -#[derive(Subcommand)] -enum TestsSubcommand { - /// Look up tests by name and print their health and metrics. - /// - /// Search CI Insights for one or more tests (by exact name or - /// glob) and report their flakiness, failure rate, and recent - /// history. Pass `--json` for machine-readable output. - Show(TestsShowCliArgs), - /// Manage the CI Insights quarantine. - /// - /// Add, remove, inspect, and list the tests held in the CI - /// Insights quarantine — the set of known-flaky tests whose - /// failures are ignored so they don't block the merge queue. - Quarantines(TestsQuarantinesArgs), -} - -#[derive(clap::Args)] -struct TestsQuarantinesArgs { - #[command(subcommand)] - command: QuarantinesSubcommand, -} - -#[derive(Subcommand)] -enum QuarantinesSubcommand { - /// Add a test to the CI Insights quarantine. - /// - /// Quarantine a test by its fully qualified name so its failures - /// stop blocking the merge queue. A reason is required; scope it - /// to a branch with `--branch`, or quarantine on all branches by - /// default. - Add(TestsQuarantineCliArgs), - /// Remove a test from the CI Insights quarantine. - /// - /// Take a test out of the quarantine so its results count again. - /// Identify it by test name or by quarantine id. - Remove(TestsUnquarantineCliArgs), - /// Print a single quarantine by test name or id. - /// - /// Show the details of one quarantined test — its reason, branch - /// scope, and when it was added. Identify it by test name or by - /// quarantine id. - Get(TestsQuarantineGetCliArgs), - /// List the tests currently in the CI Insights quarantine. - /// - /// Print every test currently held in the quarantine for the - /// repository. Pass `--json` for machine-readable output. - List(TestsQuarantinedCliArgs), -} - -#[derive(clap::Args)] -struct TestsShowCliArgs { - /// Test name(s) to look up. Glob patterns (`*`, `?`) are - /// supported by the API. - #[arg(value_name = "NAME", required = true, num_args = 1..)] - test_names: Vec, - - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg( - long, - short = 'r', - value_parser = mergify_ci::detector::parse_owner_repo, - )] - repository: Option, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. Needs an `admin` - /// application key or a user credential (`mergify auth login` - /// or a GitHub PAT): a `ci` key is answered with 403. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Restrict matches to the given pipeline name(s). - #[arg(long = "pipeline-name")] - pipeline_name: Vec, - - /// Exclude matches from the given pipeline name(s). - #[arg(long = "pipeline-name-exclude")] - pipeline_name_exclude: Vec, - - /// Restrict matches to the given job name(s). - #[arg(long = "job-name")] - job_name: Vec, - - /// Exclude matches from the given job name(s). - #[arg(long = "job-name-exclude")] - job_name_exclude: Vec, - - /// Maximum number of identities the search endpoint may return - /// per page (1–100, server default is 10). - #[arg(long = "per-page", value_parser = clap::value_parser!(u32).range(1..=100))] - per_page: Option, - - /// Emit a single JSON document to stdout instead of human prose. - #[arg(long)] - json: bool, -} - -#[derive(clap::Args)] -struct TestsQuarantineCliArgs { - /// Fully qualified name of the test to quarantine. - #[arg(value_name = "NAME")] - test_name: String, - - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg( - long, - short = 'r', - value_parser = mergify_ci::detector::parse_owner_repo, - )] - repository: Option, - - /// Reason recorded for quarantining the test. - #[arg(long)] - reason: String, - - /// Branch name or pattern to scope the quarantine to. Omit to - /// quarantine on all branches. - #[arg(long, short = 'b')] - branch: Option, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. Needs an `admin` - /// application key or a user credential (`mergify auth login` - /// or a GitHub PAT): a `ci` key is answered with 403. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Emit a single JSON document to stdout instead of human prose. - #[arg(long)] - json: bool, -} - -#[derive(clap::Args)] -struct TestsUnquarantineCliArgs { - /// Test to remove from quarantine: either its fully qualified name - /// or the quarantine id (as printed by `tests quarantines add`). - #[arg(value_name = "NAME_OR_ID")] - name_or_id: String, - - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg( - long, - short = 'r', - value_parser = mergify_ci::detector::parse_owner_repo, - )] - repository: Option, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. Needs an `admin` - /// application key or a user credential (`mergify auth login` - /// or a GitHub PAT): a `ci` key is answered with 403. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Emit a single JSON document to stdout instead of human prose. - #[arg(long)] - json: bool, -} - -#[derive(clap::Args)] -struct TestsQuarantineGetCliArgs { - /// Quarantine to print: either the test's fully qualified name or - /// the quarantine id (as printed by `tests quarantines add`). - #[arg(value_name = "NAME_OR_ID")] - name_or_id: String, - - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg( - long, - short = 'r', - value_parser = mergify_ci::detector::parse_owner_repo, - )] - repository: Option, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Emit a single JSON document to stdout instead of human prose. - #[arg(long)] - json: bool, -} - -#[derive(clap::Args)] -struct TestsQuarantinedCliArgs { - /// Repository full name (owner/repo). Detected from the CI - /// environment or the local git remote when omitted. - #[arg( - long, - short = 'r', - value_parser = mergify_ci::detector::parse_owner_repo, - )] - repository: Option, - - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Emit a single JSON document to stdout instead of human prose. - #[arg(long)] - json: bool, -} - -#[derive(clap::Args)] -struct QueueArgs { - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't', global = true)] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u', global = true)] - api_url: Option, - - /// Repository full name (owner/repo). Falls back to - /// ``GITHUB_REPOSITORY`` env var. - #[arg(long, short = 'r', global = true)] - repository: Option, - - #[command(subcommand)] - command: QueueSubcommand, -} - -#[derive(Subcommand)] -enum QueueSubcommand { - /// Pause the merge queue for the repository. - /// - /// Stop the queue from merging any pull request until it is - /// resumed — useful during an incident or release window. A - /// reason is required and shown to your team; queued pull - /// requests stay in place. - Pause(PauseCliArgs), - /// Unpause the merge queue for the repository. - /// - /// Resume merging after a pause. The queue picks up where it left - /// off with the pull requests still queued. - Unpause, - /// Show merge queue status for the repository. - /// - /// List the pull requests currently in the queue with their - /// position and state. Filter by branch with `--branch`, or pass - /// `--json` for machine-readable output. - Status(StatusCliArgs), - /// Show detailed state of a pull request in the merge queue. - /// - /// Report the full queue state of a single pull request: its - /// checks, the conditions it still needs to satisfy, and why it - /// is or isn't mergeable. Pass `--verbose` for the full checks - /// table and conditions tree, or `--json` for the raw response. - Show(ShowCliArgs), -} - -#[derive(clap::Args)] -struct PauseCliArgs { - /// Reason for pausing the queue (max 255 characters). - #[arg(long, value_parser = mergify_queue::pause::parse_reason)] - reason: String, - - /// Skip the confirmation prompt. Required in non-interactive - /// sessions. - #[arg(long = "yes-i-am-sure", default_value_t = false)] - yes_i_am_sure: bool, -} - -#[derive(clap::Args)] -struct StatusCliArgs { - /// Filter the queue by branch name. - #[arg(long, short = 'b')] - branch: Option, - - /// Emit the raw API response as a single JSON document. - #[arg(long, default_value_t = false)] - json: bool, -} - -#[derive(clap::Args)] -struct ShowCliArgs { - /// Pull request number to inspect. - #[arg(value_name = "PR_NUMBER")] - pr_number: u64, - - /// Emit the raw API response as a single JSON document. - #[arg(long, default_value_t = false)] - json: bool, -} - -#[derive(clap::Args)] -struct EventsCliArgs { - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't')] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u')] - api_url: Option, - - /// Repository full name (owner/repo). Falls back to - /// ``GITHUB_REPOSITORY`` env var. - #[arg(long, short = 'r')] - repository: Option, - - /// Only events for this pull request; omit to cover the whole - /// repository. - #[arg(long, value_name = "PR_NUMBER")] - pr: Option, - - /// How far back to look: an integer with a unit — s, m, h, d or w - /// (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days - /// (--since 90d is the widest useful window). The window always - /// ends now and is stated in the output. - #[arg(long, value_name = "DURATION", value_parser = mergify_events::list::parse_since)] - since: Option, - - /// Only events of this type (e.g. action.queue.leave). Repeat the - /// flag to match several types; values pass to the API verbatim, - /// so types newer than this CLI work too. - #[arg(long = "type", value_name = "EVENT_TYPE")] - r#type: Vec, - - /// Stop after the newest N events. The default is one API page — - /// a busy repository records thousands of events a day, so - /// fetching the whole window takes minutes. The output says when - /// the cap took effect; raise it to read further back. - #[arg( - long, - value_name = "N", - default_value_t = mergify_events::list::DEFAULT_LIMIT, - // Zero would fetch a page and then print none of it, which - // reads exactly like an empty window. - value_parser = clap::builder::RangedU64ValueParser::::new().range(1..), - )] - limit: usize, - - /// Emit a single JSON document (the raw events, newest first, - /// with the queried window echoed) instead of the timeline. - #[arg(long, default_value_t = false)] - json: bool, -} - -#[derive(clap::Args)] -struct AuthArgs { - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u', global = true)] - api_url: Option, - - #[command(subcommand)] - command: AuthSubcommand, -} - -#[derive(clap::Args)] -struct AuthLoginArgs { - /// Do not open a browser; only print the URL to open. - #[arg(long = "no-browser")] - no_browser: bool, - - /// Name this machine on the approval page instead of sending its - /// hostname. `--device-name=` sends no name at all. - #[arg(long = "device-name", value_name = "NAME")] - device_name: Option, -} - -#[derive(Subcommand)] -enum AuthSubcommand { - /// Sign in to Mergify and store the credential. - /// - /// Opens the approval page in your browser and prints the URL - /// and the code as well, so a machine with no browser can sign - /// in from the same command. The credential lands in your OS - /// keychain, or in a `0600` file when the machine has no - /// keychain to offer. - Login(AuthLoginArgs), - /// Revoke the stored credential and forget it. - /// - /// Tells the Mergify API to revoke the token as well as deleting - /// the local copy, so a machine you are handing back stops being - /// signed in everywhere. - Logout, - /// Show whether this machine is signed in to Mergify. - /// - /// Names the account, where the credential is stored, and when it - /// runs out. Checks the credential against the API rather than - /// trusting the local copy, so one revoked from the dashboard is - /// reported as invalid. - Status, -} - -#[derive(clap::Args)] -struct FreezeArgs { - /// Mergify token. Falls back to ``MERGIFY_TOKEN``, then the - /// credential ``mergify auth login`` stored for this API URL, - /// then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two - /// are deprecated for the Mergify API. - #[arg(long, short = 't', global = true)] - token: Option, - - /// Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, - /// then to the default. - #[arg(long = "api-url", short = 'u', global = true)] - api_url: Option, - - /// Repository full name (owner/repo). Falls back to - /// ``GITHUB_REPOSITORY`` env var. - #[arg(long, short = 'r', global = true)] - repository: Option, - - #[command(subcommand)] - command: FreezeSubcommand, -} - -#[derive(Subcommand)] -enum FreezeSubcommand { - /// List scheduled freezes for a repository. - /// - /// Show the freezes currently configured for the repository, with - /// their name, schedule, and state. Pass `--json` for - /// machine-readable output. - List(FreezeListCliArgs), - /// Create a new scheduled freeze. - /// - /// Add a freeze that stops the merge queue from merging while it - /// is active — for a release window, incident, or code freeze. - Create(FreezeCreateCliArgs), - /// Update an existing scheduled freeze. - /// - /// Change the settings of an existing freeze, identified by its - /// ID. Only the fields you pass are changed; the rest are left as - /// they are. - Update(FreezeUpdateCliArgs), - /// Delete a scheduled freeze. - /// - /// Remove a freeze by its ID. If the freeze is currently active a - /// reason is required, and the queue resumes merging once it's - /// gone. - Delete(FreezeDeleteCliArgs), -} - -#[derive(clap::Args)] -struct FreezeListCliArgs { - /// Emit the raw `scheduled_freezes` array as a single JSON - /// document. - #[arg(long, default_value_t = false)] - json: bool, -} - -#[derive(clap::Args)] -struct FreezeCreateCliArgs { - /// Reason for the freeze. - #[arg(long, required = true)] - reason: String, - - /// IANA timezone name (e.g. ``Europe/Paris``, ``US/Eastern``). - /// Defaults to the system timezone when omitted. - #[arg(long)] - timezone: Option, - - /// Matching condition (repeatable, e.g. `-c base=main`). - #[arg(long = "condition", short = 'c')] - condition: Vec, - - /// Start time in ISO 8601 format (default: now). - #[arg(long, value_parser = parse_naive_datetime_arg)] - start: Option, - - /// End time in ISO 8601 format (default: no end / emergency freeze). - #[arg(long, value_parser = parse_naive_datetime_arg)] - end: Option, - - /// Exclude condition (repeatable, e.g. `-e label=hotfix`). - #[arg(long = "exclude", short = 'e')] - exclude: Vec, -} - -#[derive(clap::Args)] -struct FreezeUpdateCliArgs { - /// Freeze ID (UUID). - #[arg(value_name = "FREEZE_ID")] - freeze_id: String, - - /// Reason for the freeze. - #[arg(long)] - reason: Option, - - /// IANA timezone name. - #[arg(long)] - timezone: Option, - - /// Matching condition (repeatable, e.g. `-c base=main`). - /// Passing the flag one or more times replaces the existing - /// list with the values supplied. Omitting `-c` entirely - /// leaves the stored list untouched. - #[arg(long = "condition", short = 'c')] - condition: Vec, - - /// Start time in ISO 8601 format. - #[arg(long, value_parser = parse_naive_datetime_arg)] - start: Option, - - /// End time in ISO 8601 format. - #[arg(long, value_parser = parse_naive_datetime_arg)] - end: Option, - - /// Exclude condition (repeatable). - #[arg(long = "exclude", short = 'e')] - exclude: Vec, -} - -#[derive(clap::Args)] -struct FreezeDeleteCliArgs { - /// Freeze ID (UUID). - #[arg(value_name = "FREEZE_ID")] - freeze_id: String, - - /// Reason for deleting the freeze (required if the freeze is - /// currently active). - #[arg(long = "reason")] - delete_reason: Option, -} - -/// clap `value_parser` shim for `--start` / `--end`. Delegates to -/// [`parse_naive_datetime`] and converts the typed `CliError` into a -/// stringified parser error so clap can render it as a normal -/// argument error. -fn parse_naive_datetime_arg(value: &str) -> Result { - parse_naive_datetime(value).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn parse(argv: &[&str]) -> CliRoot { - CliRoot::try_parse_from( - std::iter::once("mergify".to_string()).chain(argv.iter().map(|s| (*s).to_string())), - ) - .expect("argv parses") - } - - /// The schema is published as a release asset (not committed), so - /// guard the generator structurally rather than against a golden - /// file: it must serialize to valid JSON, expose every top-level - /// group, surface both stack sources, and never leak the hidden - /// `_internal` machinery into the public reference. - #[test] - fn cli_schema_is_well_formed() { - let v: serde_json::Value = - serde_json::from_str(&cli_schema::dump()).expect("schema is valid JSON"); - - let groups: Vec<&str> = v["command"]["commands"] - .as_array() - .expect("commands array") - .iter() - .map(|c| c["name"].as_str().expect("group name")) - .collect(); - assert_eq!( - groups, - [ - "auth", - "config", - "ci", - "tests", - "queue", - "events", - "freeze", - "stack", - "self-update", - "completions" - ] - ); - assert!(!groups.contains(&"_internal"), "hidden group leaked"); - - let stack = v["command"]["commands"] - .as_array() - .unwrap() - .iter() - .find(|c| c["name"] == "stack") - .expect("stack group"); - let sources: std::collections::BTreeSet<&str> = stack["commands"] - .as_array() - .expect("stack subcommands") - .iter() - .map(|c| c["source"].as_str().expect("source")) - .collect(); - // Pure-Rust binary: every stack subcommand is native. Locked - // in so a regression that quietly re-introduces a non-native - // source (e.g. a hand-grafted shim) shows up here. - assert_eq!( - sources, - std::collections::BTreeSet::from(["native"]), - "expected only `native` stack subcommands", - ); - - // The stack subcommand set in the generated schema must match - // the NATIVE_COMMANDS registry exactly. Under the old shim - // these were two hand-maintained lists that could drift (a - // dropped leaf would silently vanish from the reference); now - // both derive from the same clap tree, so pin the invariant. - let schema_stack: std::collections::BTreeSet<&str> = stack["commands"] - .as_array() - .unwrap() - .iter() - .map(|c| c["name"].as_str().expect("name")) - .collect(); - let registry_stack: std::collections::BTreeSet<&str> = NATIVE_COMMANDS - .iter() - .filter(|(group, _)| *group == "stack") - .map(|(_, sub)| *sub) - .collect(); - assert_eq!( - schema_stack, registry_stack, - "stack subcommands in the schema drifted from NATIVE_COMMANDS", - ); - - // The exit-code contract rides alongside the command tree, - // sourced from `mergify_core::ExitCode`. Pin the codes so a - // dropped or renumbered variant surfaces here. - let codes: Vec = v["exitCodes"] - .as_array() - .expect("exitCodes array") - .iter() - .map(|c| c["code"].as_u64().expect("code")) - .collect(); - assert_eq!(codes, [0, 1, 3, 4, 5, 6, 7, 8]); - } - - /// Golden snapshot of the whole CLI surface. Catches drift the - /// structural checks above can't — a dropped flag, a renamed leaf - /// subcommand, a changed `value_hint`, reworded help. Review - /// intentional changes with `cargo insta review`. The - /// version field is release-stamped, so redact it. - #[test] - fn cli_schema_golden() { - let schema: serde_json::Value = - serde_json::from_str(&cli_schema::dump()).expect("schema is valid JSON"); - insta::assert_json_snapshot!(schema, { ".cli.version" => "[version]" }); - } - - #[test] - fn version_const_matches_release_env_or_falls_back_to_cargo_pkg_version() { - // Mirror `build.rs` exactly: empty `MERGIFY_RELEASE_VERSION` - // is treated the same as unset, both collapse to - // `CARGO_PKG_VERSION`. The release path is exercised in - // `build-wheels.yml`'s stamp step (sets a non-empty calver, - // expects it in `--version`); this test pins both fallback - // branches so build.rs's normalisation can't drift. - let expected = match option_env!("MERGIFY_RELEASE_VERSION") { - Some(v) if !v.is_empty() => v, - _ => env!("CARGO_PKG_VERSION"), - }; - assert_eq!(VERSION, expected); - } - - #[test] - fn cli_root_exposes_version_flag() { - // Locked-in regression: a previous incarnation had - // `disable_version_flag = true` because the version source - // was wrong; switching to the env-driven const lets clap - // render `--version` properly. If a future refactor - // removes the `version = VERSION` attribute, `--version` - // silently becomes an unknown flag and this catches it. - // - // `try_parse_from(...).err().expect(...)` over `expect_err` - // because the Ok variant is `CliRoot` which doesn't - // implement `Debug` (and shouldn't — it carries no debug - // intent). - let err = CliRoot::try_parse_from(["mergify", "--version"]) - .err() - .expect("--version exits"); - assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion); - assert!( - err.to_string().contains(VERSION), - "version output should contain VERSION ({VERSION}), got: {err}", - ); - } - - #[test] - fn color_choice_folds_env_overrides_into_auto_only() { - use mergify_tui::ColorChoice; - - // `--color always|never` is the user being explicit; no env - // var may override it. - assert_eq!( - resolve_color_choice(ColorArg::Always, true, false), - ColorChoice::Always - ); - assert_eq!( - resolve_color_choice(ColorArg::Never, false, true), - ColorChoice::Never - ); - // Auto: NO_COLOR wins over FORCE_COLOR / CLICOLOR_FORCE. - assert_eq!( - resolve_color_choice(ColorArg::Auto, true, true), - ColorChoice::Never - ); - assert_eq!( - resolve_color_choice(ColorArg::Auto, false, true), - ColorChoice::Always - ); - // Auto with neither set stays Auto, i.e. defers to the TTY. - assert_eq!( - resolve_color_choice(ColorArg::Auto, false, false), - ColorChoice::Auto - ); - } - - #[test] - fn stderr_ansi_follows_the_resolved_color_choice() { - use mergify_tui::ColorChoice; - - // The log stream obeys the same decision as stdout: before - // this, `--color never` and `NO_COLOR` gave a plain stdout and - // a `-vv` stream still full of escapes. - assert!(ansi_on_stderr(ColorChoice::Always)); - assert!(!ansi_on_stderr(ColorChoice::Never)); - // `Auto` is deliberately not asserted: it is - // `stderr().is_terminal()`, which is whatever the harness was - // given, so an assertion here would either restate the branch - // or test the terminal the suite happens to run under. - } - - /// No argument anywhere in the tree may take its value from the - /// environment through clap's `env = "…"` attribute. - /// - /// Twice now that attribute broke a caller who exports the - /// variable empty. `gha-mergify-ci` sets `MERGIFY_CONFIG_PATH=""` - /// when the user pinned no path, and clap read the empty string - /// as a present-but-empty `--config`, aborting with "a value is - /// required for '--config'" (monorepo#33423). Same shape for - /// `MERGIFY_TEST_EXIT_CODE=""` and `--test-exit-code`: "cannot - /// parse integer from empty string". Env lookup belongs in the - /// resolver, where `mergify_core::env::var_non_empty` treats - /// empty as unset. - /// - /// This replaces two tests that each exported one variable empty - /// and parsed one argv. Asking the built `Command` covers every - /// argument rather than those two, and needs no process - /// environment to mutate. - #[test] - fn no_argument_takes_its_value_from_the_environment() { - fn walk(cmd: &clap::Command, path: &str, found: &mut Vec) { - for arg in cmd.get_arguments() { - if let Some(var) = arg.get_env() { - found.push(format!( - "{path} {} <- {}", - arg.get_id(), - var.to_string_lossy() - )); - } - } - for sub in cmd.get_subcommands() { - walk(sub, &format!("{path} {}", sub.get_name()), found); - } - } - - let mut found = Vec::new(); - walk(&CliRoot::command(), "mergify", &mut found); - assert!(found.is_empty(), "clap env hooks found: {found:#?}"); - } - - /// The observable half of the rule above, for the two arguments - /// it was reported on. - /// - /// The walk asks clap whether an `env = "…"` hook is declared, - /// which is the spelling that caused both regressions but not the - /// only one: `default_value_t = std::env::var(…).unwrap_or_default()` - /// or a `value_parser` that reads the environment reproduce it - /// exactly and declare no hook. This asserts what the user sees - /// instead. It needs no environment of its own — with the - /// variable unset, any of those spellings still surfaces a - /// present-but-empty value where `None` is required. - #[test] - fn an_omitted_flag_stays_omitted() { - let parsed = CliRoot::try_parse_from(["mergify", "ci", "scopes", "--write", "scopes.json"]) - .expect("argv parses"); - let Dispatch::Native(NativeCommand::CiScopes(opts)) = dispatch_from_parsed(parsed) else { - panic!("ci scopes must dispatch natively"); - }; - assert!(opts.config.is_none(), "got: {:?}", opts.config); - - let parsed = CliRoot::try_parse_from(["mergify", "ci", "junit-process", "report.xml"]) - .expect("argv parses"); - let Dispatch::Native(NativeCommand::CiJunitProcess(opts)) = dispatch_from_parsed(parsed) - else { - panic!("ci junit-process must dispatch natively"); - }; - assert!( - opts.test_exit_code.is_none(), - "got: {:?}", - opts.test_exit_code - ); - } - - #[test] - fn root_debug_flag_accepted_before_native_command() { - // Without this, clap would reject `--debug` and exit before - // any dispatch — the regression we just fixed. - let parsed = parse(&["--debug", "ci", "git-refs"]); - assert!(parsed.debug, "--debug should be parsed as true"); - assert!(matches!( - parsed.command, - Subcommands::Ci(CiArgs { - command: CiSubcommand::GitRefs(_) - }) - )); - } - - #[test] - fn root_debug_flag_accepted_after_native_group() { - // `--debug` is declared `global = true`, so it's recognised - // at any point along the subcommand chain. clap's - // hand-off prefers root, but users sometimes type it after - // the group name — both must work. - let parsed = parse(&["queue", "--debug", "status"]); - assert!(parsed.debug); - } - - // Unknown stack subcommands exit via `clap::Error::exit()` - // (process::exit(2) + "did you mean?" output), which can't - // be unit-tested without subprocess plumbing. End-to-end - // smoke is covered by clap's own conformance tests. - - #[test] - fn ci_junit_upload_dispatches_natively_via_deprecated_alias() { - // `ci junit-upload` is the deprecated alias for - // `junit-process`. Both must dispatch to the native - // orchestrator; the alias gets its own - // `NativeCommand::CiJunitUpload` variant so `run_native` - // can print the deprecation warning before forwarding. - let parsed = parse(&[ - "ci", - "junit-upload", - "-r", - "owner/repo", - "-t", - "tok", - "-b", - "main", - "report.xml", - ]); - let Dispatch::Native(NativeCommand::CiJunitUpload(opts)) = dispatch_from_parsed(parsed) - else { - panic!("ci junit-upload must dispatch to the native CiJunitUpload variant"); - }; - assert_eq!(opts.repository.as_deref(), Some("owner/repo")); - assert_eq!(opts.token.as_deref(), Some("tok")); - assert_eq!(opts.tests_target_branch.as_deref(), Some("main")); - assert_eq!(opts.files, vec!["report.xml"]); - } - - // The flag has to reach `LoginOptions`, not merely parse: an - // inverted `if no_browser` in `run_native` opens a browser for - // the user who asked for none, and every other test in the - // suite passes `browser: None` directly and would stay green. - #[test] - fn auth_login_carries_no_browser_through_dispatch() { - let Dispatch::Native(NativeCommand::AuthLogin { no_browser, .. }) = - dispatch_from_parsed(parse(&["auth", "login", "--no-browser"])) - else { - panic!("auth login must dispatch to the native AuthLogin variant"); - }; - assert!(no_browser); - - let Dispatch::Native(NativeCommand::AuthLogin { no_browser, .. }) = - dispatch_from_parsed(parse(&["auth", "login"])) - else { - panic!("auth login must dispatch to the native AuthLogin variant"); - }; - assert!(!no_browser, "a browser is the default"); - } - - // `--device-name ""` is the opt-out, so clap must hand the empty - // string through rather than reject it or read it as absent: - // absent means "send the hostname", the opposite of what was asked. - #[test] - fn auth_login_carries_device_name_through_dispatch() { - for (args, expected) in [ - ( - &["auth", "login", "--device-name", "work laptop"][..], - Some("work laptop"), - ), - (&["auth", "login", "--device-name", ""][..], Some("")), - // The portable spelling: Windows PowerShell 5.1 drops a - // `""` argument before it reaches the binary. - (&["auth", "login", "--device-name="][..], Some("")), - (&["auth", "login"][..], None), - ] { - let Dispatch::Native(NativeCommand::AuthLogin { device_name, .. }) = - dispatch_from_parsed(parse(args)) - else { - panic!("auth login must dispatch to the native AuthLogin variant"); - }; - assert_eq!(device_name.as_deref(), expected, "for {args:?}"); - } - } - - #[test] - fn events_dispatches_natively_with_every_flag() { - let parsed = parse(&[ - "events", - "-r", - "owner/repo", - "--pr", - "1740", - "--since", - "7d", - "--type", - "action.queue.leave", - "--type", - "command.queue", - "--limit", - "50", - "--json", - ]); - let Dispatch::Native(NativeCommand::Events(opts)) = dispatch_from_parsed(parsed) else { - panic!("events must dispatch to the native Events variant"); - }; - assert_eq!(opts.repository.as_deref(), Some("owner/repo")); - assert_eq!(opts.pr_number, Some(1740)); - assert_eq!(opts.since, Some(TimeDelta::days(7))); - assert_eq!( - opts.event_types, - vec!["action.queue.leave", "command.queue"], - ); - assert_eq!(opts.limit, 50); - assert!(opts.output_json); - } - - #[test] - fn events_defaults_to_repo_wide_last_24h_capped_at_one_page() { - // No --pr, no --since: the command covers the repository and - // the run applies (and states) the 24h default itself. The - // limit is not optional — an uncapped default is the bug. - let parsed = parse(&["events"]); - let Dispatch::Native(NativeCommand::Events(opts)) = dispatch_from_parsed(parsed) else { - panic!("events must dispatch to the native Events variant"); - }; - assert_eq!(opts.pr_number, None); - assert_eq!(opts.since, None); - assert!(opts.event_types.is_empty()); - assert_eq!(opts.limit, mergify_events::list::DEFAULT_LIMIT); - assert!(!opts.output_json); - } - - #[test] - fn events_rejects_a_zero_limit() { - // `--limit 0` would fetch a page and print none of it, which - // is indistinguishable from an empty window. - let Err(err) = CliRoot::try_parse_from(["mergify", "events", "--limit", "0"]) else { - panic!("a zero limit must be a usage error"); - }; - assert!(err.to_string().contains('0'), "got: {err}"); - } - - #[test] - fn events_rejects_a_since_past_the_retention_cap() { - // The impossible window dies as a usage error carrying the - // fix, not as an API 422 later. - let Err(err) = CliRoot::try_parse_from(["mergify", "events", "--since", "94d"]) else { - panic!("a 94d window must be a usage error"); - }; - assert!(err.to_string().contains("90d"), "got: {err}"); - } - - #[test] - fn tests_quarantines_add_dispatches_natively() { - let parsed = parse(&[ - "tests", - "quarantines", - "add", - "test_login", - "--reason", - "flaky", - "-r", - "owner/repo", - ]); - let Dispatch::Native(NativeCommand::TestsQuarantine(opts)) = dispatch_from_parsed(parsed) - else { - panic!("tests quarantines add must dispatch to TestsQuarantine"); - }; - assert_eq!(opts.test_name, "test_login"); - assert_eq!(opts.reason, "flaky"); - assert_eq!(opts.repository.as_deref(), Some("owner/repo")); - } - - #[test] - fn tests_quarantines_get_dispatches_natively() { - let parsed = parse(&[ - "tests", - "quarantines", - "get", - "test_login", - "-r", - "owner/repo", - ]); - let Dispatch::Native(NativeCommand::TestsQuarantineGet(opts)) = - dispatch_from_parsed(parsed) - else { - panic!("tests quarantines get must dispatch to TestsQuarantineGet"); - }; - assert_eq!(opts.name_or_id, "test_login"); - assert_eq!(opts.repository.as_deref(), Some("owner/repo")); - } - - #[test] - fn removed_flat_quarantine_commands_are_rejected_by_clap() { - // The deprecated flat commands were removed; only the - // `quarantines` subgroup routes natively now. Without the - // Python shim there's no fallback — clap rejects the - // unknown subcommand at parse time. Use `try_parse_from` - // (which returns Err instead of calling process::exit) - // so we can assert in-process. - for argv in [ - &["mergify", "tests", "quarantine"][..], - &["mergify", "tests", "unquarantine"], - &["mergify", "tests", "quarantined"], - ] { - assert!( - CliRoot::try_parse_from(argv).is_err(), - "{argv:?} must be rejected by clap (deprecated flat command)", - ); - } - // The replacement `quarantines` subgroup still parses - // (proving the regression is targeted, not blanket). - assert!( - CliRoot::try_parse_from(["mergify", "tests", "quarantines", "add", "--help"]) - .is_err_and(|e| matches!( - e.kind(), - clap::error::ErrorKind::DisplayHelp - | clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand, - )), - "the replacement subgroup must parse (help-display Err counts as parsed)", - ); - } - - #[test] - fn git_style_help_subcommand_is_supported() { - // `mergify help` and `mergify help ` must be recognized - // (clap surfaces help as a DisplayHelp "error"), not rejected - // as an unknown subcommand the way they were while - // `disable_help_subcommand` was set. - for argv in [ - &["mergify", "help"][..], - &["mergify", "help", "queue"], - &["mergify", "help", "stack", "push"], - ] { - // `CliRoot` isn't `Debug`, so drop the Ok value with - // `.err()` before inspecting the error kind. - let kind = CliRoot::try_parse_from(argv).err().map(|e| e.kind()); - assert_eq!( - kind, - Some(clap::error::ErrorKind::DisplayHelp), - "{argv:?} should display help, got {kind:?}", - ); - } - } -} diff --git a/crates/mergify-cli/src/self_update.rs b/crates/mergify-cli/src/self_update.rs deleted file mode 100644 index 1200846a..00000000 --- a/crates/mergify-cli/src/self_update.rs +++ /dev/null @@ -1,649 +0,0 @@ -//! `mergify self-update` — upgrade the running binary in place. -//! -//! Targets the `curl | sh` install channel: same release assets, -//! same `SHA256SUMS` verification, same Rust target triple -//! mapping. Users on `PyPI` / `Homebrew` should keep using their -//! package manager — we run a best-effort sanity check on the -//! binary's install path and warn (not error) when it looks -//! package-manager-owned. -//! -//! Flow: -//! -//! 1. GET `/repos/Mergifyio/mergify-cli/releases/latest` for the -//! tag and the published asset list. -//! 2. If `tag == crate::VERSION` and `--force` wasn't passed, -//! print "already up to date" and return. -//! 3. Pick the matching asset out of the release's published asset -//! list by its target-triple suffix (`.zip` on Windows, -//! `.tar.gz` elsewhere) and download it + `SHA256SUMS` via the -//! URLs the API hands back. We *discover* the asset rather than -//! reconstructing its filename: the release has embedded the -//! version in the name (`mergify--.`) and -//! could rename it again, but a binary frozen at install time -//! can't know a future scheme — matching on the stable triple -//! suffix keeps `self-update` working across renames. -//! 4. Verify the asset SHA256 against `SHA256SUMS`. Mirrors -//! `install.sh`'s line-shape validation so a malformed -//! `SHA256SUMS` can't slip past. -//! 5. Shell out to `tar -xzf` for extraction (identical to -//! `install.sh`; saves a `tar` + `flate2` crate dep). -//! 6. [`self_replace`] atomically swaps the running binary with -//! the freshly extracted one — handles the Windows -//! rename-over-a-running-exe case. - -use std::path::Path; -use std::time::Duration; - -use mergify_core::CliError; -use mergify_core::env; -use serde::Deserialize; -use sha2::Digest; -use sha2::Sha256; - -const REPO: &str = "Mergifyio/mergify-cli"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); -const DEFAULT_API_BASE: &str = "https://api.github.com"; - -/// Env-var override that points the release lookup at a fixture URL. -/// Same lever `install.sh` exposes as `MERGIFY_BASE_URL` — the CI -/// smoke test sets it to a `python3 -m http.server` fixture so the -/// full download + verify + swap path runs without hitting real -/// GitHub. The fixture serves a `latest-release.json` whose -/// `assets[].browser_download_url` point back at the same server, so -/// the discovery path is identical to the real one. Unset in real -/// use. -const BASE_URL_ENV: &str = "MERGIFY_BASE_URL"; - -/// The release-metadata URL: the fixture stub in `MERGIFY_BASE_URL` -/// mode, otherwise GitHub's `releases/latest` API. Asset URLs are -/// read from the response, never reconstructed, so we only need to -/// know where the metadata lives. -fn latest_release_url() -> String { - if let Some(base) = env::var_non_empty(BASE_URL_ENV) { - format!("{base}/latest-release.json") - } else { - format!("{DEFAULT_API_BASE}/repos/{REPO}/releases/latest") - } -} - -/// Inputs to [`run`]. Construction is owned by the CLI binding in -/// `main.rs` so this module stays UI-agnostic. -#[derive(Debug)] -pub struct Options { - /// Re-install even when the running binary already matches the - /// latest release. Useful for repairing a corrupted install. - pub force: bool, - /// Resolve and print the latest release tag, then exit without - /// touching the binary. - pub check_only: bool, -} - -#[derive(Deserialize)] -struct LatestRelease { - tag_name: String, - /// The release's published assets. GitHub returns more fields - /// per asset; we only deserialize what we download by. - #[serde(default)] - assets: Vec, -} - -#[derive(Debug, Deserialize)] -struct Asset { - name: String, - browser_download_url: String, -} - -/// Pick the single release asset whose name ends in -/// `-.`. Matching the *suffix* — not the full -/// reconstructed name — is what makes `self-update` survive an -/// asset-naming change: the prefix (`mergify-`, an embedded version, -/// or whatever a future release uses) is ignored, only the stable -/// target-triple tail has to line up. Fails closed if zero or more -/// than one asset matches. -fn select_asset<'a>(assets: &'a [Asset], target: &str, ext: &str) -> Result<&'a Asset, CliError> { - let suffix = format!("-{target}.{ext}"); - let mut matching = assets.iter().filter(|a| a.name.ends_with(&suffix)); - let found = matching.next().ok_or_else(|| { - CliError::Generic(format!( - "no release asset matching *{suffix} — the latest release ships no binary for this platform" - )) - })?; - if matching.next().is_some() { - return Err(CliError::Generic(format!( - "multiple release assets match *{suffix}; refusing to guess" - ))); - } - Ok(found) -} - -pub async fn run(opts: &Options) -> Result<(), CliError> { - let current = crate::VERSION; - let target = current_target()?; - let client = http_client()?; - let url = latest_release_url(); - let release = with_retry(3, || fetch_latest_release(&client, &url)).await?; - let latest = release.tag_name.as_str(); - - if opts.check_only { - println!("Current: {current}"); - println!("Latest: {latest}"); - return Ok(()); - } - if current == latest && !opts.force { - println!("mergify is up to date ({current})"); - return Ok(()); - } - println!("Updating mergify: {current} -> {latest}"); - - // Windows ships `.zip`, every other target `.tar.gz`. The - // release workflow packages assets the same way, so the - // extension is determined by `cfg!(windows)` here, not by - // target string-sniffing. - let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; - let asset = select_asset(&release.assets, target, ext)?; - let sums_asset = release - .assets - .iter() - .find(|a| a.name == "SHA256SUMS") - .ok_or_else(|| CliError::GitHubApi("latest release has no SHA256SUMS asset".to_string()))?; - let archive = with_retry(3, || download(&client, &asset.browser_download_url)).await?; - let sums = with_retry(3, || { - download_text(&client, &sums_asset.browser_download_url) - }) - .await?; - // Verify against the asset's *actual* published name, so the - // `SHA256SUMS` lookup matches whatever scheme the release used. - verify_checksum(&archive, &asset.name, &sums)?; - let asset_name = asset.name.clone(); - - // Stage the extracted binary in a sibling temp dir next to the - // current binary. Same filesystem keeps the final `rename` in - // [`self_replace`] atomic — on Linux/macOS a cross-fs rename - // would silently copy + delete and lose the "no half-written - // binary on disk" guarantee. - let current_exe = std::env::current_exe() - .map_err(|e| CliError::Generic(format!("locate current binary: {e}")))?; - let install_dir = current_exe - .parent() - .ok_or_else(|| CliError::Generic("current binary has no parent dir".to_string()))?; - warn_if_package_manager_owned(install_dir); - - let workdir = tempfile::tempdir_in(install_dir) - .map_err(|e| CliError::Generic(format!("create temp dir near binary: {e}")))?; - // `asset_name` comes from release metadata. Even though the - // archive's checksum is verified, the filename itself is - // untrusted input — constrain it to a bare leaf component so a - // crafted name with path separators (`../`, an absolute path) - // can't make us write the archive outside `workdir`. - let leaf = Path::new(&asset_name).file_name().ok_or_else(|| { - CliError::Generic(format!("asset name is not a plain filename: {asset_name}")) - })?; - let archive_path = workdir.path().join(leaf); - std::fs::write(&archive_path, &archive) - .map_err(|e| CliError::Generic(format!("write archive: {e}")))?; - - extract(&archive_path, workdir.path())?; - let bin_name = if cfg!(windows) { - "mergify.exe" - } else { - "mergify" - }; - let new_bin = workdir.path().join(bin_name); - if !new_bin.exists() { - return Err(CliError::Generic(format!( - "archive {asset_name} did not contain the expected binary {bin_name}" - ))); - } - - // `self_replace::self_replace` does the atomic swap. On Unix - // this is a `rename(new, current)` over the running binary - // (kernel keeps the in-use inode alive). On Windows it renames - // the current binary aside, then renames the new one into - // place — there's no truly atomic `rename`-over-a-running-exe - // on Windows, but the orphaned `.old` file is cleaned up on - // the next process exit. - self_replace::self_replace(&new_bin) - .map_err(|e| CliError::Generic(format!("replace running binary: {e}")))?; - - println!("Installed mergify {latest} to {}", current_exe.display()); - Ok(()) -} - -fn http_client() -> Result { - reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .user_agent(concat!("mergify-cli/", env!("CARGO_PKG_VERSION"))) - .build() - .map_err(|e| CliError::Generic(format!("build HTTP client: {e}"))) -} - -async fn fetch_latest_release( - client: &reqwest::Client, - url: &str, -) -> Result { - client - .get(url) - .send() - .await - .map_err(|e| CliError::GitHubApi(format!("GET {url}: {e}")))? - .error_for_status() - .map_err(|e| CliError::GitHubApi(format!("GitHub API: {e}")))? - .json::() - .await - .map_err(|e| CliError::GitHubApi(format!("parse latest-release: {e}"))) -} - -async fn download(client: &reqwest::Client, url: &str) -> Result, CliError> { - Ok(client - .get(url) - .send() - .await - .map_err(|e| CliError::GitHubApi(format!("GET {url}: {e}")))? - .error_for_status() - .map_err(|e| CliError::GitHubApi(format!("GET {url}: {e}")))? - .bytes() - .await - .map_err(|e| CliError::GitHubApi(format!("read body {url}: {e}")))? - .to_vec()) -} - -async fn download_text(client: &reqwest::Client, url: &str) -> Result { - let bytes = download(client, url).await?; - String::from_utf8(bytes) - .map_err(|e| CliError::GitHubApi(format!("non-UTF8 body from {url}: {e}"))) -} - -/// Retry a GitHub network read a few times on transient failures -/// (connect/timeout). Release metadata and asset downloads are -/// one-shot and idempotent, so a blip shouldn't fail the update. -/// Only [`CliError::GitHubApi`] is retried — that's exactly what the -/// network reads (`fetch_latest_release` / `download`) emit, so -/// integrity errors (checksum/missing asset) that are raised outside -/// the retried closure never get re-run. -async fn with_retry(attempts: u32, mut op: F) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - // 500ms, then 1s before the final try. Indexed by completed - // attempt; the last attempt's result is returned without sleeping. - let backoff = [Duration::from_millis(500), Duration::from_secs(1)]; - for attempt in 1..attempts { - match op().await { - Ok(v) => return Ok(v), - Err(e @ CliError::GitHubApi(_)) => { - let idx = (attempt as usize - 1).min(backoff.len() - 1); - eprintln!("self-update: transient GitHub error ({e}); retrying..."); - tokio::time::sleep(backoff[idx]).await; - } - Err(e) => return Err(e), - } - } - op().await -} - -/// Render digest bytes as a lowercase hex string. `sha2` 0.11 returns -/// a `hybrid_array::Array` from `finalize()` that no longer implements -/// `LowerHex`, so we format the bytes ourselves. -fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - let _ = write!(s, "{b:02x}"); - } - s -} - -/// Verify the downloaded archive's SHA256 against the entry for -/// `asset_name` in `SHA256SUMS`. Mirrors `install.sh` exactly: the -/// line must split as ` ` on whitespace where the -/// second field equals `asset_name` *literally* (not `ends_with`, -/// so `mergify-2099.1.1-fooX-target.tar.gz` can't accidentally pass the -/// check for `target.tar.gz`), and the hash field must be 64 hex -/// chars. Both layers fail closed. -fn verify_checksum(archive: &[u8], asset_name: &str, sums: &str) -> Result<(), CliError> { - let mut expected: Option<&str> = None; - for line in sums.lines() { - let mut fields = line.split_whitespace(); - let Some(hash) = fields.next() else { continue }; - let Some(name) = fields.next() else { continue }; - // Canonical `sha256sum` output is exactly two fields; bail - // on anything else even if `name == asset_name`, so a - // doctored entry with smuggled extras can't sneak past. - if fields.next().is_some() { - continue; - } - if name == asset_name { - expected = Some(hash); - break; - } - } - let expected = expected.ok_or_else(|| { - CliError::GitHubApi(format!("no checksum entry for {asset_name} in SHA256SUMS")) - })?; - if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { - return Err(CliError::GitHubApi(format!( - "malformed checksum entry for {asset_name}" - ))); - } - let mut hasher = Sha256::new(); - hasher.update(archive); - let actual = hex_encode(&hasher.finalize()); - if actual.eq_ignore_ascii_case(expected) { - Ok(()) - } else { - Err(CliError::GitHubApi(format!( - "checksum mismatch for {asset_name}: expected {expected}, got {actual}" - ))) - } -} - -/// Extract `archive` (a `.tar.gz` or `.zip`) into `dest` by -/// shelling out to the system `tar` / PowerShell `Expand-Archive`. -/// `install.sh` shells out to `tar` too — keeping the same -/// extractor for both code paths keeps "did the archive parse OK" -/// surface area tiny. -fn extract(archive: &Path, dest: &Path) -> Result<(), CliError> { - let archive_str = archive.to_string_lossy(); - let dest_str = dest.to_string_lossy(); - let status = if archive_str.ends_with(".zip") { - // PowerShell single-quoted strings escape an embedded `'` - // by doubling it. Without this, a Windows username like - // `C:\Users\O'Connor\...` would terminate the literal - // early and PowerShell would refuse the command. - let archive_quoted = archive_str.replace('\'', "''"); - let dest_quoted = dest_str.replace('\'', "''"); - std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-Command", - &format!( - "Expand-Archive -LiteralPath '{archive_quoted}' \ - -DestinationPath '{dest_quoted}' -Force" - ), - ]) - .status() - } else { - std::process::Command::new("tar") - .args(["-xzf", &archive_str, "-C", &dest_str]) - .status() - }; - let status = status.map_err(|e| CliError::Generic(format!("spawn extractor: {e}")))?; - if !status.success() { - return Err(CliError::Generic(format!( - "extractor exited with status {status}" - ))); - } - Ok(()) -} - -/// Map the running platform to the Rust target triple the release -/// workflow tags its assets with. Mirrors `install.sh`'s -/// `detect_target` — kept here as a sanity net (the release wheel -/// matrix wouldn't have shipped a binary for an unsupported -/// target, so the running binary can only exist on a known one), -/// but we still match explicitly so a future cross-build for a -/// new triple gets caught before the GitHub URL 404s. -fn current_target() -> Result<&'static str, CliError> { - let target = match (std::env::consts::OS, std::env::consts::ARCH) { - ("linux", "x86_64") => "x86_64-unknown-linux-gnu", - ("linux", "aarch64") => "aarch64-unknown-linux-gnu", - ("macos", "x86_64") => "x86_64-apple-darwin", - ("macos", "aarch64") => "aarch64-apple-darwin", - ("windows", "x86_64") => "x86_64-pc-windows-msvc", - (os, arch) => { - return Err(CliError::Generic(format!( - "no release binary published for {os}/{arch} — install from source" - ))); - } - }; - Ok(target) -} - -/// Print a notice (not an error) if the install path looks -/// package-manager-owned. Updating in place still works, but the -/// package manager will overwrite our binary on its next upgrade. -fn warn_if_package_manager_owned(install_dir: &Path) { - // Conservative list — match the path prefixes the common - // installers use, not arbitrary substrings, to avoid false - // positives. Skipping `~/.local/bin` (where the curl|sh - // installer lives) and `/usr/local/bin` (often used manually). - let path = install_dir.to_string_lossy(); - let owned_by: Option<&str> = if path.contains("/Cellar/") || path.starts_with("/opt/homebrew/") - { - Some("Homebrew") - } else if path.contains("/.local/share/uv/tools/") || path.contains("/.local/pipx/") { - Some("uv / pipx") - } else if path.contains("/site-packages/") { - Some("pip") - } else { - None - }; - if let Some(mgr) = owned_by { - eprintln!( - "warning: mergify looks like it was installed by {mgr}. \ - `self-update` will overwrite the binary, but {mgr} will \ - restore its own version on the next upgrade. Use \ - {mgr}'s upgrade command instead for a durable update." - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn fixture_archive() -> Vec { - b"pretend-this-is-a-tar.gz".to_vec() - } - - fn asset(name: &str) -> Asset { - Asset { - name: name.to_string(), - browser_download_url: format!("https://example.test/{name}"), - } - } - - #[test] - fn latest_release_url_falls_back_when_the_base_url_is_empty() { - // `gha-mergify-ci` exports unset variables as `""`, so an - // empty `MERGIFY_BASE_URL` must mean "no fixture" and not - // "fetch from `/latest-release.json`". This is the - // `var_non_empty` half of the empty-string rule; the - // overlay is what lets the case be written at all, since - // nothing may mutate the process environment. - let url = env::testing::with_var(BASE_URL_ENV, Some(""), latest_release_url); - assert_eq!( - url, - format!("{DEFAULT_API_BASE}/repos/{REPO}/releases/latest") - ); - } - - #[test] - fn latest_release_url_uses_a_non_empty_base_url() { - let url = env::testing::with_var( - BASE_URL_ENV, - Some("https://example.test"), - latest_release_url, - ); - assert_eq!(url, "https://example.test/latest-release.json"); - } - - #[test] - fn latest_release_url_defaults_when_the_base_url_is_unset() { - let url = env::testing::with_no_vars(latest_release_url); - assert_eq!( - url, - format!("{DEFAULT_API_BASE}/repos/{REPO}/releases/latest") - ); - } - - #[test] - fn select_asset_matches_versioned_name() { - let assets = [ - asset("mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz"), - asset("mergify-2099.1.1-aarch64-apple-darwin.tar.gz"), - asset("SHA256SUMS"), - ]; - let found = select_asset(&assets, "aarch64-apple-darwin", "tar.gz").unwrap(); - assert_eq!(found.name, "mergify-2099.1.1-aarch64-apple-darwin.tar.gz"); - } - - #[test] - fn select_asset_matches_legacy_unversioned_name() { - // The whole point of suffix-matching: a binary built after a - // future rename must still resolve assets from an - // old-scheme release (here the pre-#1603 name with no - // embedded version). The prefix is irrelevant. - let assets = [ - asset("mergify-aarch64-apple-darwin.tar.gz"), - asset("SHA256SUMS"), - ]; - let found = select_asset(&assets, "aarch64-apple-darwin", "tar.gz").unwrap(); - assert_eq!(found.name, "mergify-aarch64-apple-darwin.tar.gz"); - } - - #[test] - fn select_asset_errors_when_platform_absent() { - let assets = [ - asset("mergify-2099.1.1-x86_64-pc-windows-msvc.zip"), - asset("SHA256SUMS"), - ]; - let err = select_asset(&assets, "aarch64-apple-darwin", "tar.gz").unwrap_err(); - assert!(err.to_string().contains("no release asset"), "got: {err}"); - } - - #[test] - fn select_asset_rejects_ambiguous_matches() { - // Two assets with the same target suffix — refuse rather - // than silently grabbing the first. - let assets = [ - asset("mergify-2099.1.1-x86_64-apple-darwin.tar.gz"), - asset("mergify-2099.1.2-x86_64-apple-darwin.tar.gz"), - ]; - let err = select_asset(&assets, "x86_64-apple-darwin", "tar.gz").unwrap_err(); - assert!( - err.to_string().contains("multiple release assets"), - "got: {err}" - ); - } - - #[test] - fn verify_checksum_accepts_a_matching_entry() { - let archive = fixture_archive(); - let mut h = Sha256::new(); - h.update(&archive); - let hash = hex_encode(&h.finalize()); - let sums = format!("{hash} mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz\n"); - verify_checksum( - &archive, - "mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz", - &sums, - ) - .unwrap(); - } - - #[test] - fn verify_checksum_rejects_mismatch() { - let archive = fixture_archive(); - let wrong = "0".repeat(64); - let sums = format!("{wrong} mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz\n"); - let err = verify_checksum( - &archive, - "mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz", - &sums, - ) - .unwrap_err(); - assert!(err.to_string().contains("checksum mismatch")); - } - - #[test] - fn verify_checksum_rejects_missing_entry() { - let sums = "deadbeef mergify-2099.1.1-aarch64-apple-darwin.tar.gz\n"; - let err = verify_checksum( - &[], - "mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz", - sums, - ) - .unwrap_err(); - assert!(err.to_string().contains("no checksum entry")); - } - - #[test] - fn verify_checksum_does_not_accept_suffix_match() { - // Regression for the pre-fix `ends_with` lookup: a sibling - // asset whose name *ends in* `asset_name` (here - // `mergify-2099.1.1-x86_64-pc-windows-msvc.zip` ending in - // `.zip`) could be matched and pass even when the requested - // asset wasn't in the file. The literal second-field match - // must reject this. - let archive = fixture_archive(); - let mut h = Sha256::new(); - h.update(&archive); - let hash = hex_encode(&h.finalize()); - let sums = format!("{hash} mergify-2099.1.1-x86_64-pc-windows-msvc.zip\n"); - let err = verify_checksum(&archive, "msvc.zip", &sums).unwrap_err(); - assert!( - err.to_string().contains("no checksum entry"), - "expected 'no checksum entry', got: {err}", - ); - } - - #[test] - fn verify_checksum_rejects_extra_fields_on_the_entry_line() { - // Defence-in-depth against a doctored SHA256SUMS smuggling - // extra fields after the asset name (e.g. an injected - // `; rm -rf /` for a downstream shell consumer). Canonical - // `sha256sum` output is exactly two fields, anything else - // is treated as a missing entry. - let archive = fixture_archive(); - let mut h = Sha256::new(); - h.update(&archive); - let hash = hex_encode(&h.finalize()); - let sums = format!("{hash} mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz extra\n"); - let err = verify_checksum( - &archive, - "mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz", - &sums, - ) - .unwrap_err(); - assert!( - err.to_string().contains("no checksum entry"), - "expected 'no checksum entry', got: {err}", - ); - } - - #[test] - fn verify_checksum_rejects_malformed_hash_field() { - // Right asset name, wrong-shape hash. install.sh validates - // the same way so a corrupted SHA256SUMS can't slip past - // sha256sum's warn-but-pass behaviour; mirror it here. - let sums = "bogus mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz\n"; - let err = verify_checksum( - &[], - "mergify-2099.1.1-x86_64-unknown-linux-gnu.tar.gz", - sums, - ) - .unwrap_err(); - assert!(err.to_string().contains("malformed checksum entry")); - } - - #[test] - fn current_target_picks_one_of_the_known_triples() { - // We don't ship for anything else, so the call must - // succeed in the test harness — and it must return one of - // the five tagged triples we publish assets for. - let target = current_target().unwrap(); - assert!( - [ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc", - ] - .contains(&target), - "unexpected target: {target}", - ); - } -} diff --git a/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap b/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap deleted file mode 100644 index 1f00cf4f..00000000 --- a/crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap +++ /dev/null @@ -1,4108 +0,0 @@ ---- -source: crates/mergify-cli/src/main.rs -expression: schema ---- -{ - "cli": { - "about": "Mergify command-line interface", - "name": "mergify", - "version": "[version]" - }, - "command": { - "about": "Mergify command-line interface", - "aliases": [], - "args": [ - { - "default": "0", - "env": null, - "global": true, - "help": "Increase log verbosity: -v info, -vv debug, -vvv trace. Logs go to stderr so stdout stays clean for piping. `RUST_LOG` overrides this", - "id": "verbose", - "kind": "flag", - "long": "verbose", - "longHelp": "Increase log verbosity: -v info, -vv debug, -vvv trace. Logs go to stderr so stdout stays clean for piping. `RUST_LOG` overrides this", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "v", - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": true, - "help": "Shorthand for at least debug-level logging (like -vv)", - "id": "debug", - "kind": "flag", - "long": "debug", - "longHelp": "Shorthand for at least debug-level logging (like -vv)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "auto", - "env": null, - "global": true, - "help": "When to use color in terminal output", - "id": "color", - "kind": "option", - "long": "color", - "longHelp": "When to use color in terminal output", - "numArgs": "1", - "possibleValues": [ - { - "help": null, - "name": "auto" - }, - { - "help": null, - "name": "always" - }, - { - "help": null, - "name": "never" - } - ], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "COLOR" - ] - } - ], - "commands": [ - { - "about": "Sign in to Mergify, and manage the stored credential", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": true, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - } - ], - "commands": [ - { - "about": "Sign in to Mergify and store the credential", - "aliases": [], - "args": [ - { - "default": "false", - "env": null, - "global": false, - "help": "Do not open a browser; only print the URL to open", - "id": "no_browser", - "kind": "flag", - "long": "no-browser", - "longHelp": "Do not open a browser; only print the URL to open", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Name this machine on the approval page instead of sending its hostname. `--device-name=` sends no name at all", - "id": "device_name", - "kind": "option", - "long": "device-name", - "longHelp": "Name this machine on the approval page instead of sending its hostname. `--device-name=` sends no name at all", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME" - ] - } - ], - "commands": [], - "longAbout": "Sign in to Mergify and store the credential.\n\nOpens the approval page in your browser and prints the URL and the code as well, so a machine with no browser can sign in from the same command. The credential lands in your OS keychain, or in a `0600` file when the machine has no keychain to offer.", - "name": "login", - "path": [ - "mergify", - "auth", - "login" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify auth login [OPTIONS]" - }, - { - "about": "Revoke the stored credential and forget it", - "aliases": [], - "args": [], - "commands": [], - "longAbout": "Revoke the stored credential and forget it.\n\nTells the Mergify API to revoke the token as well as deleting the local copy, so a machine you are handing back stops being signed in everywhere.", - "name": "logout", - "path": [ - "mergify", - "auth", - "logout" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify auth logout [OPTIONS]" - }, - { - "about": "Show whether this machine is signed in to Mergify", - "aliases": [], - "args": [], - "commands": [], - "longAbout": "Show whether this machine is signed in to Mergify.\n\nNames the account, where the credential is stored, and when it runs out. Checks the credential against the API rather than trusting the local copy, so one revoked from the dashboard is reported as invalid.", - "name": "status", - "path": [ - "mergify", - "auth", - "status" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify auth status [OPTIONS]" - } - ], - "longAbout": "Sign in to Mergify, and manage the stored credential.\n\n`mergify auth login` runs an OAuth device flow against the Mergify API and stores the per-user token it mints, so the commands that talk to Mergify need no token in your environment.", - "name": "auth", - "path": [ - "mergify", - "auth" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify auth [OPTIONS] " - }, - { - "about": "Validate and simulate your Mergify configuration", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": true, - "help": "Path to the Mergify configuration file (auto-detected if not provided)", - "id": "config_file", - "kind": "option", - "long": "config-file", - "longHelp": "Path to the Mergify configuration file.\n\nWhen omitted, the first of these that exists is used: .mergify.yml, .mergify.yaml, .mergify/config.yml, .mergify/config.yaml, .github/mergify.yml, .github/mergify.yaml. A repository carrying more than one gets a warning on stderr naming the file in use and the ones ignored.", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "f", - "valueHint": "anyPath", - "valueNames": [ - "CONFIG_FILE" - ] - } - ], - "commands": [ - { - "about": "Validate the Mergify configuration file against the schema", - "aliases": [], - "args": [], - "commands": [], - "longAbout": "Validate the Mergify configuration file against the schema.\n\nCheck that your configuration file parses and conforms to the Mergify schema, reporting the first error with its location. Run it locally or in CI to catch mistakes before they reach the default branch. The file is auto-detected unless you pass `--config-file`.", - "name": "validate", - "path": [ - "mergify", - "config", - "validate" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify config validate [OPTIONS]" - }, - { - "about": "Simulate Mergify actions on a pull request using the local configuration", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Pull request URL (e.g. )", - "id": "pull_request", - "kind": "positional", - "long": null, - "longHelp": "Pull request URL (e.g. )", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "PULL_REQUEST_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - } - ], - "commands": [], - "longAbout": "Simulate Mergify actions on a pull request using the local configuration.\n\nEvaluate your local configuration against a real pull request and report which rules match and what actions Mergify would take — without changing anything on GitHub. Useful to test a configuration change before pushing it.", - "name": "simulate", - "path": [ - "mergify", - "config", - "simulate" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify config simulate [OPTIONS] " - } - ], - "longAbout": "Validate and simulate your Mergify configuration.\n\nCheck your `.mergify.yml` against the schema before pushing it, or simulate how Mergify's rules would evaluate against a given pull request using your local configuration.", - "name": "config", - "path": [ - "mergify", - "config" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify config [OPTIONS] " - }, - { - "about": "Run Mergify CI Insights commands from your pipeline", - "aliases": [], - "args": [], - "commands": [ - { - "about": "Send scopes tied to a pull request to Mergify", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Pull request number. When omitted, it's detected from the CI environment: under GitHub Actions from the event payload (``.pull_request.number`` in ``GITHUB_EVENT_PATH``), under Buildkite from ``BUILDKITE_PULL_REQUEST``. When none can be detected the command prints a skip message and exits 0", - "id": "pull_request", - "kind": "option", - "long": "pull-request", - "longHelp": "Pull request number. When omitted, it's detected from the CI environment: under GitHub Actions from the event payload (``.pull_request.number`` in ``GITHUB_EVENT_PATH``), under Buildkite from ``BUILDKITE_PULL_REQUEST``. When none can be detected the command prints a skip message and exits 0", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "p", - "valueHint": null, - "valueNames": [ - "PULL_REQUEST" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify application key. Falls back to ``MERGIFY_TOKEN``, then ``GITHUB_TOKEN`` and ``gh auth token`` — both deprecated for the Mergify API. The CI endpoints require an application key, so the credential ``mergify auth login`` stores is not used here", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify application key. Falls back to ``MERGIFY_TOKEN``, then ``GITHUB_TOKEN`` and ``gh auth token`` — both deprecated for the Mergify API. The CI endpoints require an application key, so the credential ``mergify auth login`` stores is not used here", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Scope to upload (repeatable)", - "id": "scope", - "kind": "option", - "long": "scope", - "longHelp": "Scope to upload (repeatable)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "s", - "valueHint": null, - "valueNames": [ - "SCOPE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "JSON file containing scopes to upload (output of ``mergify ci scopes --write``)", - "id": "scopes_json", - "kind": "option", - "long": "scopes-json", - "longHelp": "JSON file containing scopes to upload (output of ``mergify ci scopes --write``)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": "anyPath", - "valueNames": [ - "SCOPES_JSON" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Plain-text file with one scope per line", - "id": "scopes_file", - "kind": "option", - "long": "scopes-file", - "longHelp": "Plain-text file with one scope per line", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": "anyPath", - "valueNames": [ - "SCOPES_FILE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Declare that the pull request impacts every scope. The merge queue then treats it as a barrier: it is never batched or run in parallel with other pull requests. The concrete scopes are still sent alongside the flag", - "id": "all", - "kind": "flag", - "long": "all", - "longHelp": "Declare that the pull request impacts every scope. The merge queue then treats it as a barrier: it is never batched or run in parallel with other pull requests. The concrete scopes are still sent alongside the flag", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Head SHA the scopes were computed for, as 40 hexadecimal characters, so a result computed for an older head is not taken for the current one. When omitted it is detected from the CI environment: under GitHub Actions from the event payload (``.pull_request.head.sha`` in ``GITHUB_EVENT_PATH``), under Buildkite from ``BUILDKITE_COMMIT``. Pass the pull request's head, not the revision the job checked out — on a ``pull_request`` event those differ", - "id": "head_sha", - "kind": "option", - "long": "head-sha", - "longHelp": "Head SHA the scopes were computed for, as 40 hexadecimal characters, so a result computed for an older head is not taken for the current one. When omitted it is detected from the CI environment: under GitHub Actions from the event payload (``.pull_request.head.sha`` in ``GITHUB_EVENT_PATH``), under Buildkite from ``BUILDKITE_COMMIT``. Pass the pull request's head, not the revision the job checked out — on a ``pull_request`` event those differ", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "HEAD_SHA" - ] - } - ], - "commands": [], - "longAbout": "Send scopes tied to a pull request to Mergify.\n\nUpload the scopes affected by a pull request so CI Insights can attribute test results to them. Reads scopes from repeated --scope flags or from a file produced by \"mergify ci scopes --write\". Exits 0 without doing anything when no pull request can be determined.", - "name": "scopes-send", - "path": [ - "mergify", - "ci", - "scopes-send" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci scopes-send [OPTIONS]" - }, - { - "about": "Print the base/head git references for the current build", - "aliases": [], - "args": [ - { - "default": "text", - "env": null, - "global": false, - "help": "Output format: `text` (default), `shell` for eval-friendly `MERGIFY_GIT_REFS_*` lines, or `json` for a single JSON object", - "id": "format", - "kind": "option", - "long": "format", - "longHelp": "Output format: `text` (default), `shell` for eval-friendly `MERGIFY_GIT_REFS_*` lines, or `json` for a single JSON object", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "FORMAT" - ] - } - ], - "commands": [], - "longAbout": "Print the base/head git references for the current build.\n\nDetect and print the base and head git references for the build from the CI environment, as plain text, eval-friendly shell lines, or JSON. Useful for wiring later steps to the same refs Mergify computed.", - "name": "git-refs", - "path": [ - "mergify", - "ci", - "git-refs" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci git-refs [OPTIONS]" - }, - { - "about": "Print the current build's merge queue batch metadata (from the Mergify git note)", - "aliases": [], - "args": [], - "commands": [], - "longAbout": "Print the current build's merge queue batch metadata (from the Mergify git note).\n\nRead the Mergify git note attached to the current HEAD and print the merge queue batch the build belongs to. Needs only plain git and no token, so it works in any CI runner.", - "name": "queue-info", - "path": [ - "mergify", - "ci", - "queue-info" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci queue-info [OPTIONS]" - }, - { - "about": "Give the list of scopes impacted by changed files", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Path to YAML config file (auto-detected when omitted)", - "id": "config", - "kind": "option", - "long": "config", - "longHelp": "Path to YAML config file.\n\nFalls back to the MERGIFY_CONFIG_PATH environment variable, then auto-detects the first of these that exists: .mergify.yml, .mergify.yaml, .mergify/config.yml, .mergify/config.yaml, .github/mergify.yml, .github/mergify.yaml.", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": "anyPath", - "valueNames": [ - "CONFIG" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Base git reference to use to look for changed files", - "id": "base", - "kind": "option", - "long": "base", - "longHelp": "Base git reference to use to look for changed files", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BASE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Head git reference to use to look for changed files", - "id": "head", - "kind": "option", - "long": "head", - "longHelp": "Head git reference to use to look for changed files", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "HEAD" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Write the detected scopes to a file (JSON, consumed by `ci scopes-send --scopes-json`)", - "id": "write", - "kind": "option", - "long": "write", - "longHelp": "Write the detected scopes to a file (JSON, consumed by `ci scopes-send --scopes-json`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "w", - "valueHint": "anyPath", - "valueNames": [ - "WRITE" - ] - } - ], - "commands": [], - "longAbout": "Give the list of scopes impacted by changed files.\n\nCompute the configured scopes impacted by the files changed between two git references, using your Mergify configuration. On a merge queue draft pull request, return instead the scopes the merge queue decided for the batch, read from its git note, plus the merge queue scope. Print them, or write them to a file with --write for a later \"mergify ci scopes-send\".", - "name": "scopes", - "path": [ - "mergify", - "ci", - "scopes" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci scopes [OPTIONS]" - }, - { - "about": "Upload JUnit XML reports and ignore failed tests with Mergify's CI Insights Quarantine", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default (`https://api.mergify.com`)", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default (`https://api.mergify.com`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "CI Issues application key. Falls back to ``MERGIFY_TOKEN``", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "CI Issues application key. Falls back to ``MERGIFY_TOKEN``", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Test framework label (e.g. `pytest`). Optional; passed as a span attribute", - "id": "test_framework", - "kind": "option", - "long": "test-framework", - "longHelp": "Test framework label (e.g. `pytest`). Optional; passed as a span attribute", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TEST_FRAMEWORK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Test language label (e.g. `python`). Optional; passed as a span attribute", - "id": "test_language", - "kind": "option", - "long": "test-language", - "longHelp": "Test language label (e.g. `python`). Optional; passed as a span attribute", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TEST_LANGUAGE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Branch the quarantine API should look up tests on. Defaults to the PR base branch, or the head branch as a fallback", - "id": "tests_target_branch", - "kind": "option", - "long": "tests-target-branch", - "longHelp": "Branch the quarantine API should look up tests on. Defaults to the PR base branch, or the head branch as a fallback", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "b", - "valueHint": null, - "valueNames": [ - "TESTS_TARGET_BRANCH" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exit code of the test runner. When this is non-zero but no failures appear in the JUnit report, the run is flagged as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``", - "id": "test_exit_code", - "kind": "option", - "long": "test-exit-code", - "longHelp": "Exit code of the test runner. When this is non-zero but no failures appear in the JUnit report, the run is flagged as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "e", - "valueHint": null, - "valueNames": [ - "TEST_EXIT_CODE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "JUnit XML files or glob patterns (e.g. `reports/**/*.xml`). At least one path or pattern is required", - "id": "files", - "kind": "positional", - "long": null, - "longHelp": "JUnit XML files or glob patterns (e.g. `reports/**/*.xml`). At least one path or pattern is required", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "FILE" - ] - } - ], - "commands": [], - "longAbout": "Upload JUnit XML reports and ignore failed tests with Mergify's CI Insights Quarantine.\n\nParse one or more JUnit XML reports, upload the results to CI Insights, and reconcile them against the quarantine so known-flaky tests don't fail the build. Accepts file paths or glob patterns; pass the runner's exit code with --test-exit-code to detect silent failures.", - "name": "junit-process", - "path": [ - "mergify", - "ci", - "junit-process" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci junit-process [OPTIONS] ..." - }, - { - "about": "Upload JUnit XML reports (deprecated: use junit-process)", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default (`https://api.mergify.com`)", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default (`https://api.mergify.com`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "CI Issues application key. Falls back to ``MERGIFY_TOKEN``", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "CI Issues application key. Falls back to ``MERGIFY_TOKEN``", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Test framework label (e.g. `pytest`). Optional; passed as a span attribute", - "id": "test_framework", - "kind": "option", - "long": "test-framework", - "longHelp": "Test framework label (e.g. `pytest`). Optional; passed as a span attribute", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TEST_FRAMEWORK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Test language label (e.g. `python`). Optional; passed as a span attribute", - "id": "test_language", - "kind": "option", - "long": "test-language", - "longHelp": "Test language label (e.g. `python`). Optional; passed as a span attribute", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TEST_LANGUAGE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Branch the quarantine API should look up tests on. Defaults to the PR base branch, or the head branch as a fallback", - "id": "tests_target_branch", - "kind": "option", - "long": "tests-target-branch", - "longHelp": "Branch the quarantine API should look up tests on. Defaults to the PR base branch, or the head branch as a fallback", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "b", - "valueHint": null, - "valueNames": [ - "TESTS_TARGET_BRANCH" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exit code of the test runner. When this is non-zero but no failures appear in the JUnit report, the run is flagged as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``", - "id": "test_exit_code", - "kind": "option", - "long": "test-exit-code", - "longHelp": "Exit code of the test runner. When this is non-zero but no failures appear in the JUnit report, the run is flagged as a silent failure. Falls back to ``MERGIFY_TEST_EXIT_CODE``", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "e", - "valueHint": null, - "valueNames": [ - "TEST_EXIT_CODE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "JUnit XML files or glob patterns (e.g. `reports/**/*.xml`). At least one path or pattern is required", - "id": "files", - "kind": "positional", - "long": null, - "longHelp": "JUnit XML files or glob patterns (e.g. `reports/**/*.xml`). At least one path or pattern is required", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "FILE" - ] - } - ], - "commands": [], - "longAbout": "Upload JUnit XML reports (deprecated: use junit-process).\n\nDeprecated alias for junit-process, kept for backward compatibility. It runs the same processing and prints a deprecation warning; use junit-process instead.", - "name": "junit-upload", - "path": [ - "mergify", - "ci", - "junit-upload" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify ci junit-upload [OPTIONS] ..." - } - ], - "longAbout": "Run Mergify CI Insights commands from your pipeline.\n\nHelpers meant to run inside a CI job: upload test reports, detect the build's git references, report the merge queue batch the build belongs to, and compute the scopes impacted by the changed files.", - "name": "ci", - "path": [ - "mergify", - "ci" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify ci [OPTIONS] " - }, - { - "about": "Inspect the tests tracked by Mergify CI Insights", - "aliases": [], - "args": [], - "commands": [ - { - "about": "Look up tests by name and print their health and metrics", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Test name(s) to look up. Glob patterns (`*`, `?`) are supported by the API", - "id": "test_names", - "kind": "positional", - "long": null, - "longHelp": "Test name(s) to look up. Glob patterns (`*`, `?`) are supported by the API", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Restrict matches to the given pipeline name(s)", - "id": "pipeline_name", - "kind": "option", - "long": "pipeline-name", - "longHelp": "Restrict matches to the given pipeline name(s)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "PIPELINE_NAME" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exclude matches from the given pipeline name(s)", - "id": "pipeline_name_exclude", - "kind": "option", - "long": "pipeline-name-exclude", - "longHelp": "Exclude matches from the given pipeline name(s)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "PIPELINE_NAME_EXCLUDE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Restrict matches to the given job name(s)", - "id": "job_name", - "kind": "option", - "long": "job-name", - "longHelp": "Restrict matches to the given job name(s)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "JOB_NAME" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exclude matches from the given job name(s)", - "id": "job_name_exclude", - "kind": "option", - "long": "job-name-exclude", - "longHelp": "Exclude matches from the given job name(s)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "JOB_NAME_EXCLUDE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Maximum number of identities the search endpoint may return per page (1–100, server default is 10)", - "id": "per_page", - "kind": "option", - "long": "per-page", - "longHelp": "Maximum number of identities the search endpoint may return per page (1–100, server default is 10)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "PER_PAGE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document to stdout instead of human prose", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document to stdout instead of human prose", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Look up tests by name and print their health and metrics.\n\nSearch CI Insights for one or more tests (by exact name or glob) and report their flakiness, failure rate, and recent history. Pass `--json` for machine-readable output.", - "name": "show", - "path": [ - "mergify", - "tests", - "show" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify tests show [OPTIONS] ..." - }, - { - "about": "Manage the CI Insights quarantine", - "aliases": [], - "args": [], - "commands": [ - { - "about": "Add a test to the CI Insights quarantine", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Fully qualified name of the test to quarantine", - "id": "test_name", - "kind": "positional", - "long": null, - "longHelp": "Fully qualified name of the test to quarantine", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Reason recorded for quarantining the test", - "id": "reason", - "kind": "option", - "long": "reason", - "longHelp": "Reason recorded for quarantining the test", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "REASON" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Branch name or pattern to scope the quarantine to. Omit to quarantine on all branches", - "id": "branch", - "kind": "option", - "long": "branch", - "longHelp": "Branch name or pattern to scope the quarantine to. Omit to quarantine on all branches", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "b", - "valueHint": null, - "valueNames": [ - "BRANCH" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document to stdout instead of human prose", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document to stdout instead of human prose", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Add a test to the CI Insights quarantine.\n\nQuarantine a test by its fully qualified name so its failures stop blocking the merge queue. A reason is required; scope it to a branch with `--branch`, or quarantine on all branches by default.", - "name": "add", - "path": [ - "mergify", - "tests", - "quarantines", - "add" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify tests quarantines add [OPTIONS] --reason " - }, - { - "about": "Remove a test from the CI Insights quarantine", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Test to remove from quarantine: either its fully qualified name or the quarantine id (as printed by `tests quarantines add`)", - "id": "name_or_id", - "kind": "positional", - "long": null, - "longHelp": "Test to remove from quarantine: either its fully qualified name or the quarantine id (as printed by `tests quarantines add`)", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME_OR_ID" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API. Needs an `admin` application key or a user credential (`mergify auth login` or a GitHub PAT): a `ci` key is answered with 403", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document to stdout instead of human prose", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document to stdout instead of human prose", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Remove a test from the CI Insights quarantine.\n\nTake a test out of the quarantine so its results count again. Identify it by test name or by quarantine id.", - "name": "remove", - "path": [ - "mergify", - "tests", - "quarantines", - "remove" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify tests quarantines remove [OPTIONS] " - }, - { - "about": "Print a single quarantine by test name or id", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Quarantine to print: either the test's fully qualified name or the quarantine id (as printed by `tests quarantines add`)", - "id": "name_or_id", - "kind": "positional", - "long": null, - "longHelp": "Quarantine to print: either the test's fully qualified name or the quarantine id (as printed by `tests quarantines add`)", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME_OR_ID" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document to stdout instead of human prose", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document to stdout instead of human prose", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Print a single quarantine by test name or id.\n\nShow the details of one quarantined test — its reason, branch scope, and when it was added. Identify it by test name or by quarantine id.", - "name": "get", - "path": [ - "mergify", - "tests", - "quarantines", - "get" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify tests quarantines get [OPTIONS] " - }, - { - "about": "List the tests currently in the CI Insights quarantine", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Detected from the CI environment or the local git remote when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document to stdout instead of human prose", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document to stdout instead of human prose", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "List the tests currently in the CI Insights quarantine.\n\nPrint every test currently held in the quarantine for the repository. Pass `--json` for machine-readable output.", - "name": "list", - "path": [ - "mergify", - "tests", - "quarantines", - "list" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify tests quarantines list [OPTIONS]" - } - ], - "longAbout": "Manage the CI Insights quarantine.\n\nAdd, remove, inspect, and list the tests held in the CI Insights quarantine — the set of known-flaky tests whose failures are ignored so they don't block the merge queue.", - "name": "quarantines", - "path": [ - "mergify", - "tests", - "quarantines" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify tests quarantines [OPTIONS] " - } - ], - "longAbout": "Inspect the tests tracked by Mergify CI Insights.\n\nLook up a test's health and flakiness metrics, and manage the quarantine that keeps known-flaky tests from failing the merge queue.", - "name": "tests", - "path": [ - "mergify", - "tests" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify tests [OPTIONS] " - }, - { - "about": "Inspect and control the Mergify merge queue", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": true, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": true, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": true, - "help": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - } - ], - "commands": [ - { - "about": "Pause the merge queue for the repository", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Reason for pausing the queue (max 255 characters)", - "id": "reason", - "kind": "option", - "long": "reason", - "longHelp": "Reason for pausing the queue (max 255 characters)", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "REASON" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Skip the confirmation prompt. Required in non-interactive sessions", - "id": "yes_i_am_sure", - "kind": "flag", - "long": "yes-i-am-sure", - "longHelp": "Skip the confirmation prompt. Required in non-interactive sessions", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Pause the merge queue for the repository.\n\nStop the queue from merging any pull request until it is resumed — useful during an incident or release window. A reason is required and shown to your team; queued pull requests stay in place.", - "name": "pause", - "path": [ - "mergify", - "queue", - "pause" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify queue pause [OPTIONS] --reason " - }, - { - "about": "Unpause the merge queue for the repository", - "aliases": [], - "args": [], - "commands": [], - "longAbout": "Unpause the merge queue for the repository.\n\nResume merging after a pause. The queue picks up where it left off with the pull requests still queued.", - "name": "unpause", - "path": [ - "mergify", - "queue", - "unpause" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify queue unpause [OPTIONS]" - }, - { - "about": "Show merge queue status for the repository", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Filter the queue by branch name", - "id": "branch", - "kind": "option", - "long": "branch", - "longHelp": "Filter the queue by branch name", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "b", - "valueHint": null, - "valueNames": [ - "BRANCH" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit the raw API response as a single JSON document", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit the raw API response as a single JSON document", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Show merge queue status for the repository.\n\nList the pull requests currently in the queue with their position and state. Filter by branch with `--branch`, or pass `--json` for machine-readable output.", - "name": "status", - "path": [ - "mergify", - "queue", - "status" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify queue status [OPTIONS]" - }, - { - "about": "Show detailed state of a pull request in the merge queue", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Pull request number to inspect", - "id": "pr_number", - "kind": "positional", - "long": null, - "longHelp": "Pull request number to inspect", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "PR_NUMBER" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit the raw API response as a single JSON document", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit the raw API response as a single JSON document", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Show detailed state of a pull request in the merge queue.\n\nReport the full queue state of a single pull request: its checks, the conditions it still needs to satisfy, and why it is or isn't mergeable. Pass `--verbose` for the full checks table and conditions tree, or `--json` for the raw response.", - "name": "show", - "path": [ - "mergify", - "queue", - "show" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify queue show [OPTIONS] " - } - ], - "longAbout": "Inspect and control the Mergify merge queue.\n\nCheck the status of queued pull requests, inspect a single pull request's queue state, and pause or resume merging for a repository.", - "name": "queue", - "path": [ - "mergify", - "queue" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify queue [OPTIONS] " - }, - { - "about": "Browse the events Mergify recorded for the repository", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Only events for this pull request; omit to cover the whole repository", - "id": "pr", - "kind": "option", - "long": "pr", - "longHelp": "Only events for this pull request; omit to cover the whole repository", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "PR_NUMBER" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "How far back to look: an integer with a unit — s, m, h, d or w (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days (--since 90d is the widest useful window). The window always ends now and is stated in the output", - "id": "since", - "kind": "option", - "long": "since", - "longHelp": "How far back to look: an integer with a unit — s, m, h, d or w (e.g. 30m, 12h, 7d). Defaults to 24h; the log retains 90 days (--since 90d is the widest useful window). The window always ends now and is stated in the output", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "DURATION" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Only events of this type (e.g. action.queue.leave). Repeat the flag to match several types; values pass to the API verbatim, so types newer than this CLI work too", - "id": "type", - "kind": "option", - "long": "type", - "longHelp": "Only events of this type (e.g. action.queue.leave). Repeat the flag to match several types; values pass to the API verbatim, so types newer than this CLI work too", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "EVENT_TYPE" - ] - }, - { - "default": "100", - "env": null, - "global": false, - "help": "Stop after the newest N events. The default is one API page — a busy repository records thousands of events a day, so fetching the whole window takes minutes. The output says when the cap took effect; raise it to read further back", - "id": "limit", - "kind": "option", - "long": "limit", - "longHelp": "Stop after the newest N events. The default is one API page — a busy repository records thousands of events a day, so fetching the whole window takes minutes. The output says when the cap took effect; raise it to read further back", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "N" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit a single JSON document (the raw events, newest first, with the queried window echoed) instead of the timeline", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit a single JSON document (the raw events, newest first, with the queried window echoed) instead of the timeline", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Browse the events Mergify recorded for the repository.\n\nList the activity log as a timeline — queue enters and leaves, merges, commands, CI Insights, every event type — for the whole repository or one pull request (`--pr`). The output always states the time window it covers: the last 24 hours by default, up to the 90 days the log retains (`--since 90d`), and the newest page of events within it (`--limit`).", - "name": "events", - "path": [ - "mergify", - "events" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify events [OPTIONS]" - }, - { - "about": "Schedule and manage merge freezes", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": true, - "help": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "Mergify token. Falls back to ``MERGIFY_TOKEN``, then the credential ``mergify auth login`` stored for this API URL, then ``GITHUB_TOKEN`` and ``gh auth token`` — the last two are deprecated for the Mergify API", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": null, - "env": null, - "global": true, - "help": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "id": "api_url", - "kind": "option", - "long": "api-url", - "longHelp": "Mergify API URL. Falls back to ``MERGIFY_API_URL`` env var, then to the default", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [ - "API_URL" - ] - }, - { - "default": null, - "env": null, - "global": true, - "help": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "Repository full name (owner/repo). Falls back to ``GITHUB_REPOSITORY`` env var", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "r", - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - } - ], - "commands": [ - { - "about": "List scheduled freezes for a repository", - "aliases": [], - "args": [ - { - "default": "false", - "env": null, - "global": false, - "help": "Emit the raw `scheduled_freezes` array as a single JSON document", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit the raw `scheduled_freezes` array as a single JSON document", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "List scheduled freezes for a repository.\n\nShow the freezes currently configured for the repository, with their name, schedule, and state. Pass `--json` for machine-readable output.", - "name": "list", - "path": [ - "mergify", - "freeze", - "list" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify freeze list [OPTIONS]" - }, - { - "about": "Create a new scheduled freeze", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Reason for the freeze", - "id": "reason", - "kind": "option", - "long": "reason", - "longHelp": "Reason for the freeze", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "REASON" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "IANA timezone name (e.g. ``Europe/Paris``, ``US/Eastern``). Defaults to the system timezone when omitted", - "id": "timezone", - "kind": "option", - "long": "timezone", - "longHelp": "IANA timezone name (e.g. ``Europe/Paris``, ``US/Eastern``). Defaults to the system timezone when omitted", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TIMEZONE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Matching condition (repeatable, e.g. `-c base=main`)", - "id": "condition", - "kind": "option", - "long": "condition", - "longHelp": "Matching condition (repeatable, e.g. `-c base=main`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "c", - "valueHint": null, - "valueNames": [ - "CONDITION" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Start time in ISO 8601 format (default: now)", - "id": "start", - "kind": "option", - "long": "start", - "longHelp": "Start time in ISO 8601 format (default: now)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "START" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "End time in ISO 8601 format (default: no end / emergency freeze)", - "id": "end", - "kind": "option", - "long": "end", - "longHelp": "End time in ISO 8601 format (default: no end / emergency freeze)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "END" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exclude condition (repeatable, e.g. `-e label=hotfix`)", - "id": "exclude", - "kind": "option", - "long": "exclude", - "longHelp": "Exclude condition (repeatable, e.g. `-e label=hotfix`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "e", - "valueHint": null, - "valueNames": [ - "EXCLUDE" - ] - } - ], - "commands": [], - "longAbout": "Create a new scheduled freeze.\n\nAdd a freeze that stops the merge queue from merging while it is active — for a release window, incident, or code freeze.", - "name": "create", - "path": [ - "mergify", - "freeze", - "create" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify freeze create [OPTIONS] --reason " - }, - { - "about": "Update an existing scheduled freeze", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Freeze ID (UUID)", - "id": "freeze_id", - "kind": "positional", - "long": null, - "longHelp": "Freeze ID (UUID)", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "FREEZE_ID" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Reason for the freeze", - "id": "reason", - "kind": "option", - "long": "reason", - "longHelp": "Reason for the freeze", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REASON" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "IANA timezone name", - "id": "timezone", - "kind": "option", - "long": "timezone", - "longHelp": "IANA timezone name", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TIMEZONE" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Matching condition (repeatable, e.g. `-c base=main`). Passing the flag one or more times replaces the existing list with the values supplied. Omitting `-c` entirely leaves the stored list untouched", - "id": "condition", - "kind": "option", - "long": "condition", - "longHelp": "Matching condition (repeatable, e.g. `-c base=main`). Passing the flag one or more times replaces the existing list with the values supplied. Omitting `-c` entirely leaves the stored list untouched", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "c", - "valueHint": null, - "valueNames": [ - "CONDITION" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Start time in ISO 8601 format", - "id": "start", - "kind": "option", - "long": "start", - "longHelp": "Start time in ISO 8601 format", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "START" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "End time in ISO 8601 format", - "id": "end", - "kind": "option", - "long": "end", - "longHelp": "End time in ISO 8601 format", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "END" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Exclude condition (repeatable)", - "id": "exclude", - "kind": "option", - "long": "exclude", - "longHelp": "Exclude condition (repeatable)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "e", - "valueHint": null, - "valueNames": [ - "EXCLUDE" - ] - } - ], - "commands": [], - "longAbout": "Update an existing scheduled freeze.\n\nChange the settings of an existing freeze, identified by its ID. Only the fields you pass are changed; the rest are left as they are.", - "name": "update", - "path": [ - "mergify", - "freeze", - "update" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify freeze update [OPTIONS] " - }, - { - "about": "Delete a scheduled freeze", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Freeze ID (UUID)", - "id": "freeze_id", - "kind": "positional", - "long": null, - "longHelp": "Freeze ID (UUID)", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "FREEZE_ID" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Reason for deleting the freeze (required if the freeze is currently active)", - "id": "delete_reason", - "kind": "option", - "long": "reason", - "longHelp": "Reason for deleting the freeze (required if the freeze is currently active)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "DELETE_REASON" - ] - } - ], - "commands": [], - "longAbout": "Delete a scheduled freeze.\n\nRemove a freeze by its ID. If the freeze is currently active a reason is required, and the queue resumes merging once it's gone.", - "name": "delete", - "path": [ - "mergify", - "freeze", - "delete" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify freeze delete [OPTIONS] " - } - ], - "longAbout": "Schedule and manage merge freezes.\n\nCreate, list, update, and delete freezes that temporarily stop the merge queue from merging — for release windows, incidents, or code freezes.", - "name": "freeze", - "path": [ - "mergify", - "freeze" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify freeze [OPTIONS] " - }, - { - "about": "Create and maintain stacked pull requests", - "aliases": [], - "args": [], - "commands": [ - { - "about": "Create a new stack branch", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Name of the new branch", - "id": "name", - "kind": "positional", - "long": null, - "longHelp": "Name of the new branch", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "NAME" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Base branch to fork from, formatted as `REMOTE/BRANCH` (e.g. `origin/main`). When omitted, the trunk is resolved from the current branch's tracking info or `refs/remotes/origin/HEAD`", - "id": "base", - "kind": "option", - "long": "base", - "longHelp": "Base branch to fork from, formatted as `REMOTE/BRANCH` (e.g. `origin/main`). When omitted, the trunk is resolved from the current branch's tracking info or `refs/remotes/origin/HEAD`", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "b", - "valueHint": null, - "valueNames": [ - "BASE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Checkout the new branch after creation. This is the default. Pass `--no-checkout` to keep the current branch checked out", - "id": "checkout", - "kind": "flag", - "long": "checkout", - "longHelp": "Checkout the new branch after creation. This is the default. Pass `--no-checkout` to keep the current branch checked out", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Leave the current branch checked out and just create the new branch ref", - "id": "no_checkout", - "kind": "flag", - "long": "no-checkout", - "longHelp": "Leave the current branch checked out and just create the new branch ref", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Create a new stack branch.\n\nCreate a branch that tracks your trunk and start a fresh stack on top of it. The new branch is checked out by default; pass `--no-checkout` to only create the ref and stay on the current branch.", - "name": "new", - "path": [ - "mergify", - "stack", - "new" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack new [OPTIONS] " - }, - { - "about": "Attach a note explaining why a commit was amended", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Target commit. Accepts a SHA prefix, a ref (`HEAD~1`, branch name, …), or a Change-Id prefix (resolved against the stack walk). Defaults to HEAD", - "id": "commit", - "kind": "positional", - "long": null, - "longHelp": "Target commit. Accepts a SHA prefix, a ref (`HEAD~1`, branch name, …), or a Change-Id prefix (resolved against the stack walk). Defaults to HEAD", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMIT" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Note message. If omitted, opens `$GIT_EDITOR` / `$VISUAL` / `$EDITOR` / `vi` on a tempfile", - "id": "message", - "kind": "option", - "long": "message", - "longHelp": "Note message. If omitted, opens `$GIT_EDITOR` / `$VISUAL` / `$EDITOR` / `vi` on a tempfile", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "m", - "valueHint": null, - "valueNames": [ - "MESSAGE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Append to an existing note instead of replacing", - "id": "append", - "kind": "flag", - "long": "append", - "longHelp": "Append to an existing note instead of replacing", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Remove the note on the target commit. Mutually exclusive with `--message` and `--append`", - "id": "remove", - "kind": "flag", - "long": "remove", - "longHelp": "Remove the note on the target commit. Mutually exclusive with `--message` and `--append`", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Attach a note explaining why a commit was amended.\n\nRecord a note on a stack commit describing why it changed; the note is surfaced when reviewing the stack. Edit it in your editor or pass `-m`, add to an existing note with `--append`, or clear it with `--remove`. Defaults to the commit at HEAD.", - "name": "note", - "path": [ - "mergify", - "stack", - "note" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack note [OPTIONS] [COMMIT]" - }, - { - "about": "Edit a commit in the stack", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commit to pause the rebase on. Accepts a SHA prefix or a Change-Id prefix; omit for a fully interactive rebase", - "id": "commit", - "kind": "positional", - "long": null, - "longHelp": "Commit to pause the rebase on. Accepts a SHA prefix or a Change-Id prefix; omit for a fully interactive rebase", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMIT" - ] - } - ], - "commands": [], - "longAbout": "Edit a commit in the stack.\n\nPause an interactive rebase on the target commit so you can amend it, then resume. Omit the commit to start a fully interactive rebase of the whole stack.", - "name": "edit", - "path": [ - "mergify", - "stack", - "edit" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack edit [OPTIONS] [COMMIT]" - }, - { - "about": "Drop commits from the stack", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commits to drop. Each accepts a SHA prefix or a Change-Id prefix", - "id": "commits", - "kind": "positional", - "long": null, - "longHelp": "Commits to drop. Each accepts a SHA prefix or a Change-Id prefix", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMITS" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without dropping", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without dropping", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Drop commits from the stack.\n\nRemove one or more commits from the stack and rebase the commits above them down. Each accepts a SHA prefix or a Change-Id prefix. Use `--dry-run` to preview the resulting order first.", - "name": "drop", - "path": [ - "mergify", - "stack", - "drop" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack drop [OPTIONS] ..." - }, - { - "about": "Fold commits into their parent", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commits to fold into their parent", - "id": "commits", - "kind": "positional", - "long": null, - "longHelp": "Commits to fold into their parent", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMITS" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without rebasing", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without rebasing", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Fold commits into their parent.\n\nSquash each given commit into the commit below it, discarding the folded commit's message (the parent's message is kept). Each accepts a SHA prefix or a Change-Id prefix. Use `--dry-run` to preview.", - "name": "fixup", - "path": [ - "mergify", - "stack", - "fixup" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack fixup [OPTIONS] ..." - }, - { - "about": "Change a commit's message", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commit to reword. Accepts a SHA prefix or a Change-Id prefix", - "id": "commit", - "kind": "positional", - "long": null, - "longHelp": "Commit to reword. Accepts a SHA prefix or a Change-Id prefix", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMIT" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "New message. When omitted, `git rebase -i` pauses at the target and opens `$GIT_EDITOR`", - "id": "message", - "kind": "option", - "long": "message", - "longHelp": "New message. When omitted, `git rebase -i` pauses at the target and opens `$GIT_EDITOR`", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "m", - "valueHint": null, - "valueNames": [ - "MESSAGE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without rebasing", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without rebasing", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Change a commit's message.\n\nRewrite the message of a commit in the stack. Pass `-m` to set the new message inline, or omit it to edit the message in your editor. Use `--dry-run` to preview.", - "name": "reword", - "path": [ - "mergify", - "stack", - "reword" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack reword [OPTIONS] " - }, - { - "about": "Reorder the stack's commits", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Every commit in the stack, in the order you want them rebased into. All stack commits must be listed", - "id": "commits", - "kind": "positional", - "long": null, - "longHelp": "Every commit in the stack, in the order you want them rebased into. All stack commits must be listed", - "numArgs": "1..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMITS" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without reordering", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without reordering", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Reorder the stack's commits.\n\nRebase the stack into the order you list. List every commit in the stack — all of them must appear, in the new order. Each accepts a SHA prefix or a Change-Id prefix. Use `--dry-run` to preview.", - "name": "reorder", - "path": [ - "mergify", - "stack", - "reorder" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack reorder [OPTIONS] ..." - }, - { - "about": "Move a commit within the stack", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commit to move. Accepts a SHA prefix or a Change-Id prefix", - "id": "commit", - "kind": "positional", - "long": null, - "longHelp": "Commit to move. Accepts a SHA prefix or a Change-Id prefix", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMIT" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Where to put it: `first`, `last`, `before`, `after`", - "id": "position", - "kind": "positional", - "long": null, - "longHelp": "Where to put it: `first`, `last`, `before`, `after`", - "numArgs": "1", - "possibleValues": [ - { - "help": null, - "name": "first" - }, - { - "help": null, - "name": "last" - }, - { - "help": null, - "name": "before" - }, - { - "help": null, - "name": "after" - } - ], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "POSITION" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Required when `position` is `before` or `after`", - "id": "target", - "kind": "positional", - "long": null, - "longHelp": "Required when `position` is `before` or `after`", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TARGET" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without moving", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without moving", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Move a commit within the stack.\n\nMove one commit to a new position in the stack: to the `first` or `last` slot, or `before`/`after` another commit (passed as TARGET). Use `--dry-run` to preview.", - "name": "move", - "path": [ - "mergify", - "stack", - "move" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack move [OPTIONS] [TARGET]" - }, - { - "about": "Squash commits into a target commit", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "`SRC1 SRC2 ... into TARGET` — must contain exactly one `into` token; everything before is a source, the single token after is the target", - "id": "tokens", - "kind": "positional", - "long": null, - "longHelp": "`SRC1 SRC2 ... into TARGET` — must contain exactly one `into` token; everything before is a source, the single token after is the target", - "numArgs": "3..", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKENS" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Final commit message (required to rename; otherwise the target's message is kept)", - "id": "message", - "kind": "option", - "long": "message", - "longHelp": "Final commit message (required to rename; otherwise the target's message is kept)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "m", - "valueHint": null, - "valueNames": [ - "MESSAGE" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without rebasing", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without rebasing", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Squash commits into a target commit.\n\nFold one or more source commits into a target commit, reordering them adjacent to it first. Use the form `SRC... into TARGET`. Pass `-m` to set the combined message; otherwise the target's message is kept. Use `--dry-run` to preview.", - "name": "squash", - "path": [ - "mergify", - "stack", - "squash" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack squash [OPTIONS] ..." - }, - { - "about": "Check out a pull request stack from GitHub", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Stack to check out: its remote branch name, or the URL of any pull request in it", - "id": "target", - "kind": "positional", - "long": null, - "longHelp": "Stack to check out: its remote branch name, or the URL of any pull request in it", - "numArgs": "1", - "possibleValues": [], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "TARGET" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "`owner/repo`. Falls back to the URL of `--trunk`'s remote. Ignored when the target is a pull request URL, which carries its own", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "`owner/repo`. Falls back to the URL of `--trunk`'s remote. Ignored when the target is a pull request URL, which carries its own", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Local branch name. Defaults to the stack branch minus your own stack branch prefix, or to its last segment when the stack isn't under that prefix", - "id": "branch", - "kind": "option", - "long": "branch", - "longHelp": "Local branch name. Defaults to the stack branch minus your own stack branch prefix, or to its last segment when the stack isn't under that prefix", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BRANCH" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without checking out", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without checking out", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "id": "trunk", - "kind": "option", - "long": "trunk", - "longHelp": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TRUNK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - } - ], - "commands": [], - "longAbout": "Check out a pull request stack from GitHub.\n\nIdentify the stack by its remote branch name, or by the URL of any pull request in it — the middle of the stack works as well as the tip. Creates a local branch tracking the stack's leaf, so you can continue working on a stack created elsewhere (a teammate's, or your own from another machine).", - "name": "checkout", - "path": [ - "mergify", - "stack", - "checkout" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack checkout [OPTIONS] " - }, - { - "about": "Sync the stack with its trunk", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Author of the stack. Defaults to the token's user", - "id": "author", - "kind": "option", - "long": "author", - "longHelp": "Author of the stack. Defaults to the token's user", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "AUTHOR" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Override the stack branch prefix", - "id": "branch_prefix", - "kind": "option", - "long": "branch-prefix", - "longHelp": "Override the stack branch prefix", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BRANCH_PREFIX" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Show the plan without changing anything", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Show the plan without changing anything", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "id": "trunk", - "kind": "option", - "long": "trunk", - "longHelp": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TRUNK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - } - ], - "commands": [], - "longAbout": "Sync the stack with its trunk.\n\nFetch the latest trunk, drop commits whose pull request has already merged, and rebase the remaining stack on top. Use `--dry-run` to preview.", - "name": "sync", - "path": [ - "mergify", - "stack", - "sync" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack sync [OPTIONS]" - }, - { - "about": "Push the stack and create or update its pull requests", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Author of the stack. Defaults to the token's user", - "id": "author", - "kind": "option", - "long": "author", - "longHelp": "Author of the stack. Defaults to the token's user", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "AUTHOR" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Override the stack branch prefix", - "id": "branch_prefix", - "kind": "option", - "long": "branch-prefix", - "longHelp": "Override the stack branch prefix", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BRANCH_PREFIX" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "id": "trunk", - "kind": "option", - "long": "trunk", - "longHelp": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TRUNK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Skip the rebase step. By default `stack push` rebases on trunk before pushing when there are no approvals to dismiss", - "id": "skip_rebase", - "kind": "flag", - "long": "skip-rebase", - "longHelp": "Skip the rebase step. By default `stack push` rebases on trunk before pushing when there are no approvals to dismiss", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "R", - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Force the rebase even when PRs are approved (the rebase will dismiss the reviews). Mutually exclusive with `--skip-rebase`", - "id": "force_rebase", - "kind": "flag", - "long": "force-rebase", - "longHelp": "Force the rebase even when PRs are approved (the rebase will dismiss the reviews). Mutually exclusive with `--skip-rebase`", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Only push the bottom commit of the stack", - "id": "next_only", - "kind": "flag", - "long": "next-only", - "longHelp": "Only push the bottom commit of the stack", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "x", - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Dry-run: render the plan + the rebase decision and exit", - "id": "dry_run", - "kind": "flag", - "long": "dry-run", - "longHelp": "Dry-run: render the plan + the rebase decision and exit", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "n", - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Open new PRs as drafts. Default falls back to git config `mergify-cli.stack-create-as-draft` (`false` when unset)", - "id": "create_as_draft", - "kind": "option", - "long": "draft", - "longHelp": "Open new PRs as drafts. Default falls back to git config `mergify-cli.stack-create-as-draft` (`false` when unset)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "d", - "valueHint": null, - "valueNames": [ - "CREATE_AS_DRAFT" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Don't rewrite the PR title + body from the commit message; only update the rendered Depends-On chain in the body. Default falls back to git config `mergify-cli.stack-keep-pr-title-body` (`false` when unset)", - "id": "keep_pull_request_title_and_body", - "kind": "option", - "long": "keep-pull-request-title-and-body", - "longHelp": "Don't rewrite the PR title + body from the commit message; only update the rendered Depends-On chain in the body. Default falls back to git config `mergify-cli.stack-keep-pr-title-body` (`false` when unset)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "k", - "valueHint": null, - "valueNames": [ - "KEEP_PULL_REQUEST_TITLE_AND_BODY" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Don't create new PRs; surface would-be-created ones in the plan instead", - "id": "only_update_existing_pulls", - "kind": "flag", - "long": "only-update-existing-pulls", - "longHelp": "Don't create new PRs; surface would-be-created ones in the plan instead", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "u", - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Suppress the revision-history sticky comment update. Default falls back to git config `mergify-cli.stack-revision-history` (`true` when unset)", - "id": "revision_history", - "kind": "option", - "long": "no-revision-history", - "longHelp": "Suppress the revision-history sticky comment update. Default falls back to git config `mergify-cli.stack-revision-history` (`true` when unset)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REVISION_HISTORY" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Pass `--no-verify` to `git push` (skips local pre-push hooks)", - "id": "no_verify", - "kind": "flag", - "long": "no-verify", - "longHelp": "Pass `--no-verify` to `git push` (skips local pre-push hooks)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Don't register the stack with GitHub's native Stacks API. By default, `mergify stack push` registers it, so GitHub shows it as a stack and holds the ordering (the PR descriptions then carry no `Depends-On:` header). Where the API isn't available, registration is skipped and the push reports `not registered on GitHub`. Default falls back to git config `mergify-cli.stack-github-native` (`true` when unset)", - "id": "github_native", - "kind": "option", - "long": "no-github-native", - "longHelp": "Don't register the stack with GitHub's native Stacks API. By default, `mergify stack push` registers it, so GitHub shows it as a stack and holds the ordering (the PR descriptions then carry no `Depends-On:` header). Where the API isn't available, registration is skipped and the push reports `not registered on GitHub`. Default falls back to git config `mergify-cli.stack-github-native` (`true` when unset)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "GITHUB_NATIVE" - ] - } - ], - "commands": [], - "longAbout": "Push the stack and create or update its pull requests.\n\nWalk the local stack, optionally rebase it on trunk, push each commit to its own branch, and create or update the matching pull request and its Depends-On chain on GitHub. Use `--dry-run` to preview the plan and the rebase decision without touching anything.", - "name": "push", - "path": [ - "mergify", - "stack", - "push" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack push [OPTIONS]" - }, - { - "about": "List the stack's commits and their pull requests", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Author of the stack. Defaults to the token's user", - "id": "author", - "kind": "option", - "long": "author", - "longHelp": "Author of the stack. Defaults to the token's user", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "AUTHOR" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Override the stack branch prefix", - "id": "branch_prefix", - "kind": "option", - "long": "branch-prefix", - "longHelp": "Override the stack branch prefix", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BRANCH_PREFIX" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "id": "trunk", - "kind": "option", - "long": "trunk", - "longHelp": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TRUNK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Emit machine-readable JSON", - "id": "json", - "kind": "flag", - "long": "json", - "longHelp": "Emit machine-readable JSON", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "List the stack's commits and their pull requests.\n\nShow each commit in the current stack alongside its pull request, CI, and review state. Pass `--verbose` for per-check and per-reviewer detail, or `--json` for machine-readable output.", - "name": "list", - "path": [ - "mergify", - "stack", - "list" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack list [OPTIONS]" - }, - { - "about": "Open a stack commit's pull request in the browser", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Commit whose pull request to open. Accepts a SHA prefix or a Change-Id prefix. When omitted: on a terminal, pick interactively from the stack (type to fuzzy-filter, HEAD preselected); otherwise defaults to HEAD", - "id": "commit", - "kind": "positional", - "long": null, - "longHelp": "Commit whose pull request to open. Accepts a SHA prefix or a Change-Id prefix. When omitted: on a terminal, pick interactively from the stack (type to fuzzy-filter, HEAD preselected); otherwise defaults to HEAD", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "COMMIT" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Author of the stack. Defaults to the token's user", - "id": "author", - "kind": "option", - "long": "author", - "longHelp": "Author of the stack. Defaults to the token's user", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "AUTHOR" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "id": "repository", - "kind": "option", - "long": "repository", - "longHelp": "`owner/repo`. Falls back to the URL of `--trunk`'s remote", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "REPOSITORY" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Override the stack branch prefix", - "id": "branch_prefix", - "kind": "option", - "long": "branch-prefix", - "longHelp": "Override the stack branch prefix", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "BRANCH_PREFIX" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "id": "trunk", - "kind": "option", - "long": "trunk", - "longHelp": "Target trunk as `REMOTE/BRANCH`. Defaults to the resolved trunk for the current branch", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": "t", - "valueHint": null, - "valueNames": [ - "TRUNK" - ] - }, - { - "default": null, - "env": null, - "global": false, - "help": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "id": "token", - "kind": "option", - "long": "token", - "longHelp": "GitHub token (falls back to `MERGIFY_TOKEN` / `GITHUB_TOKEN` / `gh auth token`)", - "numArgs": "1", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [ - "TOKEN" - ] - } - ], - "commands": [], - "longAbout": "Open a stack commit's pull request in the browser.\n\nOpen the GitHub pull request for a commit in the stack in your default browser. Defaults to the commit at HEAD.", - "name": "open", - "path": [ - "mergify", - "stack", - "open" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack open [OPTIONS] [COMMIT]" - }, - { - "about": "Show or install the stack git hooks", - "aliases": [], - "args": [ - { - "default": "false", - "env": null, - "global": false, - "help": "Install or upgrade hooks", - "id": "do_setup", - "kind": "flag", - "long": "setup", - "longHelp": "Install or upgrade hooks", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Force reinstall wrappers (use with --setup)", - "id": "force", - "kind": "flag", - "long": "force", - "longHelp": "Force reinstall wrappers (use with --setup)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "f", - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Show or install the stack git hooks.\n\nReport the status of the git hooks that keep `Change-Id` trailers on your commits (stacks rely on them to track commits across rebases). Pass `--setup` to install or upgrade them.", - "name": "hooks", - "path": [ - "mergify", - "stack", - "hooks" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack hooks [OPTIONS]" - }, - { - "about": "Install the stack git hooks", - "aliases": [], - "args": [ - { - "default": "false", - "env": null, - "global": false, - "help": "Force reinstall of hook wrappers, even if user modified them", - "id": "force", - "kind": "flag", - "long": "force", - "longHelp": "Force reinstall of hook wrappers, even if user modified them", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": "f", - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Check status only (use 'stack hooks' instead)", - "id": "check", - "kind": "flag", - "long": "check", - "longHelp": "Check status only (use 'stack hooks' instead)", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Install the stack git hooks.\n\nInstall or upgrade the git hooks that add `Change-Id` trailers to your commits. This is an alias for `stack hooks --setup`; pass `--check` to report status instead of installing.", - "name": "setup", - "path": [ - "mergify", - "stack", - "setup" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify stack setup [OPTIONS]" - } - ], - "longAbout": "Create and maintain stacked pull requests.\n\nManage a stack of dependent branches and their pull requests: create, push, sync, reorder, reword, squash, and check out stacks built on top of your trunk.", - "name": "stack", - "path": [ - "mergify", - "stack" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify stack [OPTIONS] " - }, - { - "about": "Update mergify to the latest release", - "aliases": [], - "args": [ - { - "default": "false", - "env": null, - "global": false, - "help": "Re-download and re-install even when the running binary already matches the latest release tag. Useful for repairing a corrupted install without bumping the version", - "id": "force", - "kind": "flag", - "long": "force", - "longHelp": "Re-download and re-install even when the running binary already matches the latest release tag. Useful for repairing a corrupted install without bumping the version", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - }, - { - "default": "false", - "env": null, - "global": false, - "help": "Print the current and latest release tags and exit without touching the binary", - "id": "check", - "kind": "flag", - "long": "check", - "longHelp": "Print the current and latest release tags and exit without touching the binary", - "numArgs": "0", - "possibleValues": [], - "required": false, - "short": null, - "valueHint": null, - "valueNames": [] - } - ], - "commands": [], - "longAbout": "Update mergify to the latest release.\n\nDownload the newest published binary, verify it against the release `SHA256SUMS`, and atomically replace the running executable. Uses the same artifact and checksum contract as the `curl | sh` installer.", - "name": "self-update", - "path": [ - "mergify", - "self-update" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify self-update [OPTIONS]" - }, - { - "about": "Print a shell completion script (e.g. `mergify completions zsh`)", - "aliases": [], - "args": [ - { - "default": null, - "env": null, - "global": false, - "help": "Shell to generate a completion script for", - "id": "shell", - "kind": "positional", - "long": null, - "longHelp": "Shell to generate a completion script for", - "numArgs": "1", - "possibleValues": [ - { - "help": null, - "name": "bash" - }, - { - "help": null, - "name": "elvish" - }, - { - "help": null, - "name": "fish" - }, - { - "help": null, - "name": "powershell" - }, - { - "help": null, - "name": "zsh" - } - ], - "required": true, - "short": null, - "valueHint": null, - "valueNames": [ - "SHELL" - ] - } - ], - "commands": [], - "longAbout": "Print a shell completion script (e.g. `mergify completions zsh`)", - "name": "completions", - "path": [ - "mergify", - "completions" - ], - "source": "native", - "subcommandRequired": false, - "usage": "mergify completions [OPTIONS] " - } - ], - "longAbout": "Mergify command-line interface.\n\nDrive Mergify from your terminal. Validate and simulate your configuration, manage and inspect the merge queue, schedule merge freezes, look at the tests tracked by CI Insights, and create and maintain stacked pull requests.\n\nMost commands talk to the Mergify API and need a token. Set `MERGIFY_TOKEN` (or `GITHUB_TOKEN`) to apply it to every command, or pass `--token` to an individual command — there is no global `--token` on `mergify` itself. Run `mergify --help` (or the git-style `mergify help `) for detailed help and options on any command.", - "name": "mergify", - "path": [ - "mergify" - ], - "source": "native", - "subcommandRequired": true, - "usage": "mergify [OPTIONS] " - }, - "exitCodes": [ - { - "code": 0, - "description": "Command completed successfully.", - "name": "Success" - }, - { - "code": 1, - "description": "Unclassified runtime failure (I/O error, bug, or captured panic).", - "name": "GenericError" - }, - { - "code": 3, - "description": "Stack, branch, or commit not found.", - "name": "StackNotFound" - }, - { - "code": 4, - "description": "Rebase or merge conflict.", - "name": "Conflict" - }, - { - "code": 5, - "description": "GitHub API request failed.", - "name": "GitHubApiError" - }, - { - "code": 6, - "description": "Mergify API request failed.", - "name": "MergifyApiError" - }, - { - "code": 7, - "description": "CLI invariant violated (e.g. command run outside a valid context).", - "name": "InvalidState" - }, - { - "code": 8, - "description": "Configuration or credentials missing, unparseable, or failing validation.", - "name": "ConfigurationError" - } - ], - "generator": "mergify _internal dump-cli-schema", - "schemaVersion": 1 -} diff --git a/crates/mergify-cli/tests/fixtures/junit_fail.xml b/crates/mergify-cli/tests/fixtures/junit_fail.xml deleted file mode 100644 index 598ffdff..00000000 --- a/crates/mergify-cli/tests/fixtures/junit_fail.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - def test_failed() -> None: - > assert 1 == 0 - E assert 1 == 0 - - tests/test_func.py:6: AssertionError - - - - diff --git a/crates/mergify-cli/tests/live_smoke.rs b/crates/mergify-cli/tests/live_smoke.rs deleted file mode 100644 index c0859e9b..00000000 --- a/crates/mergify-cli/tests/live_smoke.rs +++ /dev/null @@ -1,1017 +0,0 @@ -//! Live smoke tests against the real Mergify API. -//! -//! Each test fires when the real API's URL, auth, or wire format -//! diverges from what the CLI expects. API-hitting tests skip -//! (early-return with a `SKIP:` line) unless their token -//! (`LIVE_TEST_MERGIFY_TOKEN_CI` or `LIVE_TEST_MERGIFY_TOKEN_ADMIN`) -//! is set in the env. Locally-evaluated tests run unconditionally. -//! Driven by `.github/workflows/func-tests-live.yaml` on every PR. -//! -//! Tests are grouped by the credential they need, under a banner -//! per group — a test's banner is the index of which secret it -//! consumes, so keep a test under the one matching its helper. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::Duration; - -use serde_json::Value; - -const API_URL: &str = "https://api.mergify.com"; -const REPOSITORY: &str = "mergify-clients-testing/mergify-cli-repo"; -const PULL_REQUEST: &str = "1"; - -/// CLI invocation timeout. -const CLI_TIMEOUT: Duration = Duration::from_secs(30); - -/// Env vars the CLI auto-detects from the surrounding CI runner. -/// Scrub them so a developer running tests inside GitHub Actions -/// or Buildkite doesn't get different behavior than a clean -/// laptop run. -const CI_ENV_VARS: &[&str] = &[ - "CI", - "GITHUB_ACTIONS", - "GITHUB_REPOSITORY", - "GITHUB_REF", - "GITHUB_HEAD_REF", - "GITHUB_BASE_REF", - "GITHUB_EVENT_PATH", - "GITHUB_EVENT_NAME", - "GITHUB_OUTPUT", - "GITHUB_STEP_SUMMARY", - "GITHUB_TOKEN", - "BUILDKITE", - "BUILDKITE_PULL_REQUEST", - "BUILDKITE_PULL_REQUEST_BASE_BRANCH", - "BUILDKITE_BRANCH", - "BUILDKITE_COMMIT", - "MERGIFY_API_URL", - "MERGIFY_TOKEN", - "MERGIFY_CONFIG_PATH", - "MERGIFY_TEST_EXIT_CODE", - "ACTIONS_STEP_DEBUG", -]; - -struct CliResult { - returncode: i32, - stdout: String, - stderr: String, -} - -impl CliResult { - /// Combined stream for grep-style assertions where the message - /// could land on either stdout or stderr depending on the - /// command. - fn combined(&self) -> String { - format!("{}{}", self.stdout, self.stderr) - } - - fn context(&self) -> String { - format!("\nstdout:\n{}\nstderr:\n{}", self.stdout, self.stderr) - } -} - -/// Path to the freshly-built `mergify` binary. Cargo sets -/// `CARGO_BIN_EXE_` for each `[[bin]]` target in the test -/// binary's env at compile time — no need to shell out to find -/// it, and no risk of accidentally picking up an installed -/// binary on `$PATH`. -fn mergify_binary() -> &'static Path { - Path::new(env!("CARGO_BIN_EXE_mergify")) -} - -/// Run `mergify ` with a scrubbed env and a fresh tmp cwd. -/// -/// Closes stdin so an accidental interactive prompt fails fast -/// instead of blocking. Caps wall-clock at [`CLI_TIMEOUT`] so a -/// pathological hang doesn't drag the CI matrix down with it. -/// -/// **Concurrency.** Cargo's stock test harness runs every -/// `#[test]` in this binary in a single process across a thread -/// pool (default = number of CPUs). A shared CWD or shared -/// tempdir would let parallel tests race on filesystem state, so -/// each call allocates its own `TempDir` that lives only for the -/// duration of the call. -fn cli(args: &[&str]) -> CliResult { - cli_with(args, &[], None) -} - -/// Variant of [`cli`] that adds env vars and an explicit cwd. -/// Tests that need to drop a config file before invoking the CLI -/// (e.g. `ci_scopes_select_all_when_no_base`) pass `cwd` so the -/// CLI runs inside the directory holding that file. -fn cli_with(args: &[&str], extra_env: &[(&str, &str)], cwd: Option<&Path>) -> CliResult { - let scrub: HashSet<&str> = CI_ENV_VARS.iter().copied().collect(); - let mut cmd = Command::new(mergify_binary()); - cmd.args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env_clear(); - // The one place the whole environment is read on purpose: this - // builds the *child's* environment out of ours, minus the CI - // variables a runner exports. Not the process-wide read the lint - // is aimed at. - // - // `vars_os`, not `vars`: the latter panics on a single variable - // that is not valid Unicode, and one Latin-1 export on the host - // would take down every case here before it spawned anything. - // `Command::env` takes `OsStr`, so nothing is lost. - #[allow(clippy::disallowed_methods)] - for (k, v) in std::env::vars_os() { - if !k.to_str().is_some_and(|k| scrub.contains(k)) { - cmd.env(k, v); - } - } - for (k, v) in extra_env { - cmd.env(k, v); - } - // Per-call cwd dir. Either honour the caller's explicit cwd - // (kept by them across the call), or allocate a fresh - // `TempDir` here that drops after the subprocess exits. - let owned_tmp = if cwd.is_none() { - Some(tempfile::tempdir().expect("alloc per-call tmpdir")) - } else { - None - }; - let cwd_path: &Path = cwd.unwrap_or_else(|| owned_tmp.as_ref().unwrap().path()); - cmd.current_dir(cwd_path); - - let mut child = cmd - .spawn() - .unwrap_or_else(|e| panic!("spawn mergify {args:?}: {e}")); - let pid = child.id(); - // `wait_timeout` keeps the test from hanging forever on a - // hung binary. On timeout we kill the process and surface a - // clear panic rather than letting cargo's own timeout (longer - // and less specific) absorb it. - let Some(status) = wait_timeout(&mut child, CLI_TIMEOUT) else { - let _ = child.kill(); - let _ = child.wait(); - panic!("mergify {args:?} exceeded {CLI_TIMEOUT:?} (pid {pid})"); - }; - let mut stdout = String::new(); - let mut stderr = String::new(); - if let Some(mut s) = child.stdout.take() { - use std::io::Read; - let _ = s.read_to_string(&mut stdout); - } - if let Some(mut s) = child.stderr.take() { - use std::io::Read; - let _ = s.read_to_string(&mut stderr); - } - CliResult { - // `code()` is `None` on signal termination; surface it as - // a sentinel exit so assertions still produce a useful - // error message instead of unwrap-panicking on `None`. - returncode: status.code().unwrap_or(-1), - stdout, - stderr, - } -} - -/// Poll [`Child::try_wait`] up to `timeout`. `std` doesn't ship -/// a built-in wait-with-timeout (the `wait_timeout` crate does, -/// but pulling it in for one call site is overkill). Loop with a -/// short sleep — the CLI either exits in milliseconds (most -/// tests) or the 30-second cap fires. -fn wait_timeout( - child: &mut std::process::Child, - timeout: Duration, -) -> Option { - let deadline = std::time::Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(status)) => return Some(status), - Ok(None) => { - if std::time::Instant::now() >= deadline { - return None; - } - std::thread::sleep(Duration::from_millis(50)); - } - Err(e) => panic!("wait on mergify child: {e}"), - } - } -} - -/// A token from the environment, or `None` when it is unset, empty, -/// or whitespace — the CI secret is empty on a fork's build, and a -/// stray newline in a locally exported one would otherwise be sent -/// as the bearer. -fn non_blank_env(name: &str) -> Option { - let value = mergify_core::env::var(name)?.trim().to_string(); - (!value.is_empty()).then_some(value) -} - -/// Look up `LIVE_TEST_MERGIFY_TOKEN_CI`, the key scoped to what a -/// CI job does. Empty / unset = skip the test (early return with -/// `SKIP:` printed to stderr so the cargo test log shows what was -/// skipped). -fn live_token() -> Option { - non_blank_env("LIVE_TEST_MERGIFY_TOKEN_CI") -} - -/// Token for endpoints the CI-scoped key cannot reach: the -/// queue-management family (pause/unpause, freeze CRUD, queue -/// status/show) and CI-Insights test reads. Separated from -/// [`live_token`] because the CI-scoped token is answered with 403 -/// by both — the narrow CI key is the intended design, so a 403 -/// here is a test on the wrong token, never a key to widen. -/// -/// Covers `search/tests` only. `tests/{test_id}` is narrowed the -/// same way but no live test reaches it: the one `tests show` case -/// queries a name that cannot match, which returns before the -/// details fetch. A rename or wire-format drift on that route -/// still ships green. -fn live_admin_token() -> Option { - non_blank_env("LIVE_TEST_MERGIFY_TOKEN_ADMIN") -} - -/// 8 random hex-ish chars, so concurrent or repeated runs never -/// fight over the same row on the shared canary repository. -/// `tempfile`'s name generation is already a dependency and draws -/// from `getrandom`, so it saves pulling in a uuid crate for this. -fn unique_suffix() -> String { - let dir = tempfile::tempdir().expect("tempdir for entropy"); - let name = dir - .path() - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("00000000") - .to_string(); - // tempdir names are like ".tmpXXXXXX" — take a tail slice so - // the literal prefix is not included. - let tail: String = name.chars().rev().take(8).collect(); - tail.chars().rev().collect::() -} - -/// Helper for "early-return on missing token". Cargo's stock -/// test harness doesn't have a "skip" outcome — early-returning -/// counts as pass. Logging `SKIP:` to stderr makes the elided -/// run visible in the CI log so a missing token in CI doesn't -/// silently mark the test green. -macro_rules! skip_if_unset { - ($var:expr) => { - match $var { - Some(v) => v, - None => { - eprintln!("SKIP: {} (env var unset)", stringify!($var)); - return; - } - } - }; -} - -/// RAII cleanup for `queue_pause_unpause_roundtrip` — runs -/// `queue unpause` from `Drop` so the test leaves the canary -/// repo's queue unpaused even when an assertion panics -/// mid-test. Defined at module level (not inside the test fn) to -/// avoid clippy's `items_after_statements`. Warns instead of -/// asserting in `drop` so a real failure isn't masked by a -/// cleanup-time panic. -struct UnpauseOnDrop<'a> { - token: &'a str, -} -impl Drop for UnpauseOnDrop<'_> { - fn drop(&mut self) { - let unpause = cli(&[ - "queue", - "unpause", - "--api-url", - API_URL, - "--token", - self.token, - "--repository", - REPOSITORY, - ]); - if unpause.returncode != 0 { - eprintln!("WARNING: cleanup unpause failed{}", unpause.context()); - } - if !unpause.stdout.contains("Queue resumed") { - eprintln!( - "WARNING: cleanup unpause didn't print confirmation{}", - unpause.context() - ); - } - } -} - -/// RAII cleanup for `freeze_create_update_delete_roundtrip` — -/// runs `freeze delete` from `Drop` so the test leaves no -/// orphan freezes on the canary repo. Same warn-don't-panic -/// posture as [`UnpauseOnDrop`]. -struct DeleteFreezeOnDrop<'a> { - token: &'a str, - freeze_id: &'a str, - reason: &'a str, -} -impl Drop for DeleteFreezeOnDrop<'_> { - fn drop(&mut self) { - let delete = cli(&[ - "freeze", - "--api-url", - API_URL, - "--token", - self.token, - "--repository", - REPOSITORY, - "delete", - self.freeze_id, - "--reason", - &format!("{}-cleanup", self.reason), - ]); - if delete.returncode != 0 { - eprintln!("WARNING: freeze cleanup delete failed{}", delete.context()); - } - if !delete.stdout.to_lowercase().contains("deleted") { - eprintln!( - "WARNING: freeze cleanup delete didn't print confirmation{}", - delete.context() - ); - } - } -} - -// --------------------------------------------------------------- -// Queue endpoints (admin token). -// --------------------------------------------------------------- - -#[test] -fn queue_pause_unpause_roundtrip() { - // `PUT` + `DELETE /v1/repos/{owner}/{repo}/merge-queue/pause`. - // - // Uses the admin-scoped token because pause/unpause hits the - // queue-admin endpoint and the CI-scoped token is rejected - // (403) by design. - // - // Round-trip so the test repo's queue is left in the same - // state we found it in even when an assertion fails (unpause - // runs from the cleanup guard). Tolerant of a leaked paused - // state from a previous interrupted run — the second pause - // just refreshes the reason. - let token = skip_if_unset!(live_admin_token()); - - let pause = cli(&[ - "queue", - "pause", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--reason", - "func-tests-live-smoke", - "--yes-i-am-sure", - ]); - - // Module-level `UnpauseOnDrop` runs the cleanup in Drop so - // an assertion panic below still unpauses the canary repo. - let _guard = UnpauseOnDrop { token: &token }; - - assert_eq!(pause.returncode, 0, "queue pause failed{}", pause.context()); - assert!( - pause.stdout.contains("Queue paused"), - "queue pause did not print confirmation{}", - pause.context() - ); - // Guard runs here, asserts unpause behaved. -} - -#[test] -fn queue_status() { - // `GET /v1/repos/{owner}/{repo}/merge-queue/status`. - // - // `--json` mode is a passthrough of the API response, so the - // smoke test only checks that the call succeeds and parses as - // JSON. The contract preserved across the Python → Rust port - // is the URL, the auth, and that the response is a JSON - // object. - let token = skip_if_unset!(live_admin_token()); - - // Group-level options (`--token` / `--api-url` / - // `--repository`) come BEFORE the subcommand. Click required - // this on the Python side (options live on the `@queue` - // group); clap accepts both orders via `global = true`. Put - // them on the group so the same invocation shape works - // against both ends of the port. - let result = cli(&[ - "queue", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "status", - "--json", - ]); - assert_eq!( - result.returncode, - 0, - "queue status failed{}", - result.context() - ); - let payload: Value = serde_json::from_str(&result.stdout).unwrap_or_else(|e| { - panic!( - "queue status --json emitted non-JSON output\nerror: {e}\nstdout:\n{}", - result.stdout - ) - }); - assert!( - payload.is_object(), - "queue status --json must emit a JSON object\nstdout:\n{}", - result.stdout - ); -} - -#[test] -fn queue_show_not_in_queue() { - // `GET /v1/repos/{owner}/{repo}/merge-queue/pull/{n}` 404 path. - // - // Calls with a PR number that is almost certainly not in the - // queue (the test repo has far fewer than this many PRs). - // A not-queued PR is a normal queryable state, so the command - // reports it on stdout and exits 0 — that contract is what this - // test pins. - // - // Testing the 404 path (instead of a real queued PR) makes - // the test independent of whether PR #1 happens to be queued - // at run time. The endpoint reachability, auth, and 404 - // mapping are the parts that would silently break on a URL - // or schema drift. - let token = skip_if_unset!(live_admin_token()); - - let result = cli(&[ - "queue", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "show", - "99999999", - ]); - assert_eq!( - result.returncode, - 0, - "expected a not-queued PR to exit 0, got {}{}", - result.returncode, - result.context() - ); - assert!( - result - .stdout - .to_lowercase() - .contains("not in the merge queue"), - "expected 'not in the merge queue' message on stdout{}", - result.context() - ); -} - -// --------------------------------------------------------------- -// Freeze endpoints (admin token). -// --------------------------------------------------------------- - -#[test] -fn freeze_list() { - // `GET /v1/repos/{owner}/{repo}/scheduled_freeze`. - // - // `--json` mode is a passthrough of the inner - // `scheduled_freezes` array. The smoke test only checks the - // call succeeds and parses as a JSON array — the contract - // preserved across the Python → Rust port is the URL, the - // auth, and the array shape of the `--json` output. - let token = skip_if_unset!(live_admin_token()); - - let result = cli(&[ - "freeze", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "list", - "--json", - ]); - assert_eq!( - result.returncode, - 0, - "freeze list failed{}", - result.context() - ); - let payload: Value = serde_json::from_str(&result.stdout).unwrap_or_else(|e| { - panic!( - "freeze list --json emitted non-JSON output\nerror: {e}\nstdout:\n{}", - result.stdout - ) - }); - assert!( - payload.is_array(), - "freeze list --json must emit a JSON array\nstdout:\n{}", - result.stdout - ); -} - -#[test] -fn freeze_create_update_delete_roundtrip() { - // `POST` + `PATCH` + `POST .../{id}/delete` round-trip on - // `/v1/repos/{owner}/{repo}/scheduled_freeze`. - // - // Schedules a freeze far in the future (2099) so we don't - // disturb real merges in the test repo. Cleanup runs from a - // Drop guard so the freeze is deleted even if an assertion in - // the middle of the test fails — and the test is also - // tolerant of a leaked freeze from a previous interrupted run - // (the create still succeeds because each run uses a unique - // reason; the orphan can be cleaned up out of band). - // - // The Mergify API requires `delete_reason` on every delete - // (the Python `--reason` help text says "required if freeze - // is active", but the server returns 422 for a missing key - // regardless of the freeze's active state). The test always - // passes `--reason` so the cleanup succeeds on the - // server-validated path. - let token = skip_if_unset!(live_admin_token()); - - // Unique reason so concurrent or repeated runs don't fight - // over the same row. - let reason = format!("func-tests-live-smoke-{}", unique_suffix()); - - let create = cli(&[ - "freeze", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "create", - "--reason", - &reason, - "--timezone", - "UTC", - "--start", - "2099-01-01T00:00:00", - "--end", - "2099-01-02T00:00:00", - ]); - assert_eq!( - create.returncode, - 0, - "freeze create failed{}", - create.context() - ); - - // Pull the UUID out of the create output. The Python side - // emits `ID: ` via `_print_freeze`; the Rust port emits - // the same block. Search the combined stream because a - // warning could end up on stderr on either side. - let combined = create.combined(); - let id_re = regex::Regex::new(r"ID:\s+([0-9a-fA-F-]{36})").unwrap(); - let freeze_id = id_re - .captures(&combined) - .and_then(|c| c.get(1)) - .map_or_else( - || { - panic!( - "could not find freeze ID in create output{}", - create.context() - ) - }, - |m| m.as_str().to_string(), - ); - - let _guard = DeleteFreezeOnDrop { - token: &token, - freeze_id: &freeze_id, - reason: &reason, - }; - - let updated_reason = format!("{reason}-updated"); - let update = cli(&[ - "freeze", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "update", - &freeze_id, - "--reason", - &updated_reason, - ]); - assert_eq!( - update.returncode, - 0, - "freeze update failed{}", - update.context() - ); - assert!( - update.stdout.contains(&updated_reason), - "freeze update did not echo the new reason{}", - update.context() - ); - // Guard runs here. -} - -// --------------------------------------------------------------- -// CI-Insights tests family (admin token). -// --------------------------------------------------------------- - -#[test] -fn tests_show_no_match() { - // `GET /v1/ci/{owner}/repositories/{repo}/search/tests` - // round-trip. Queries a guaranteed-nonexistent name so the - // test is independent of whatever live test data the canary - // repository currently holds. A green run proves auth, URL - // routing, and JSON deserialization for the search endpoint - // — the empty-match path returns exit 0 with a - // `{"tests": []}` payload on stdout. - let token = skip_if_unset!(live_admin_token()); - - let result = cli(&[ - "tests", - "show", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--json", - "__mergify_cli_smoke_no_such_test__", - ]); - assert_eq!( - result.returncode, - 0, - "tests show failed{}", - result.context() - ); - let payload: Value = serde_json::from_str(&result.stdout).unwrap_or_else(|e| { - panic!( - "tests show --json emitted non-JSON output\nerror: {e}\nstdout:\n{}", - result.stdout - ) - }); - assert_eq!( - payload, - serde_json::json!({"tests": []}), - "expected empty `tests` list for nonexistent test name, got:\n{}", - result.stdout - ); -} - -/// RAII cleanup for `tests_quarantine_add_remove_roundtrip` — -/// runs `quarantines remove` from `Drop` so a failed assertion -/// mid-test still leaves the canary repository clean. Same -/// warn-don't-panic posture as [`DeleteFreezeOnDrop`]: panicking -/// in `Drop` during an unwind aborts the process and buries the -/// real assertion message. -struct RemoveQuarantineOnDrop<'a> { - token: &'a str, - test_name: &'a str, -} -impl Drop for RemoveQuarantineOnDrop<'_> { - fn drop(&mut self) { - let remove = cli(&[ - "tests", - "quarantines", - "remove", - "--api-url", - API_URL, - "--token", - self.token, - "--repository", - REPOSITORY, - self.test_name, - ]); - // Two outcomes leave the repository clean: exit 0, when - // the body failed before its own remove, and the happy - // path's exit 6 — `MergifyApiError` — carrying - // `not_found`'s `'' is not quarantined`, because the - // body already removed the row. - // - // Both halves are load-bearing. Exit 6 alone is every - // Mergify API error, a 403 on a narrowed scope included; - // the message alone would swallow any failure whose text - // happens to contain that phrase. Anything else is a - // cleanup that did not happen, and the row is still there. - let already_gone = remove.returncode - == i32::from(mergify_core::ExitCode::MergifyApiError.as_u8()) - && remove.combined().contains("is not quarantined"); - if remove.returncode != 0 && !already_gone { - eprintln!("WARNING: quarantine cleanup failed{}", remove.context()); - } - } -} - -#[test] -fn tests_quarantine_add_remove_roundtrip() { - // `POST` + `DELETE` round-trip on - // `/v1/ci/{owner}/repositories/{repo}/quarantines`. - // - // MRGFY-9001 dropped `ci_application_key` from both of these - // alongside `search/tests`, and nothing here noticed, because - // the quarantine mutations had no live coverage at all — the - // wiremock tests in `tests_quarantine.rs` cannot see a scope - // change, since a mock server never authenticates. This is - // that canary: it fires on an auth, URL or wire-format change - // to either endpoint. - // - // Quarantines a name no real suite reports, so the entry can - // never suppress a genuine failure on the canary repository - // even if cleanup is skipped entirely. - let token = skip_if_unset!(live_admin_token()); - - let test_name = format!("__mergify_cli_smoke_quarantine_{}__", unique_suffix()); - let reason = "func-tests-live-smoke"; - - let add = cli(&[ - "tests", - "quarantines", - "add", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--reason", - reason, - "--json", - &test_name, - ]); - assert_eq!(add.returncode, 0, "quarantines add failed{}", add.context()); - - // Registered before the payload is parsed: an unparseable - // body still means the row was created server-side. - let _cleanup = RemoveQuarantineOnDrop { - token: &token, - test_name: &test_name, - }; - - let added: Value = serde_json::from_str(&add.stdout).unwrap_or_else(|e| { - panic!( - "quarantines add --json emitted non-JSON output\nerror: {e}\nstdout:\n{}", - add.stdout - ) - }); - assert_eq!( - added["test_name"], - serde_json::json!(test_name), - "add echoed a different test name\nstdout:\n{}", - add.stdout - ); - assert!( - added["id"].as_str().is_some_and(|id| !id.is_empty()), - "add emitted no quarantine id\nstdout:\n{}", - add.stdout - ); - - // Remove by name, which is the path that resolves the id via - // the list endpoint — so one run covers both the `ci`-allowed - // read and the admin-only delete. - let remove = cli(&[ - "tests", - "quarantines", - "remove", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--json", - &test_name, - ]); - assert_eq!( - remove.returncode, - 0, - "quarantines remove failed{}", - remove.context() - ); -} - -// --------------------------------------------------------------- -// CI commands — locally evaluated, no token needed. -// --------------------------------------------------------------- - -#[test] -fn ci_git_refs_fallback() { - // `mergify ci git-refs` falls back to `HEAD^..HEAD` when no - // CI provider env is set. The scrubbed env (CI_ENV_VARS) and - // tmp cwd land the detector on the literal-string fallback. - let result = cli(&["ci", "git-refs"]); - assert_eq!( - result.returncode, - 0, - "ci git-refs failed{}", - result.context() - ); - // Pin the exact two-line output. Substring matches would let - // added lines slip through silently, which defeats the "pin - // the contract" intent. - assert_eq!( - result.stdout, "Base: HEAD^\nHead: HEAD\n", - "output drifted from the pinned format\nstdout: {:?}", - result.stdout - ); -} - -#[test] -fn ci_queue_info_outside_mq() { - // `mergify ci queue-info` exits INVALID_STATE (7) when not - // running on an MQ draft PR. Scrubbed env + tmp cwd force - // the "no MQ context" path. - let result = cli(&["ci", "queue-info"]); - assert_eq!( - result.returncode, - 7, - "expected INVALID_STATE (7), got {}{}", - result.returncode, - result.context() - ); - assert!( - result.combined().to_lowercase().contains("merge queue"), - "expected MQ-context message{}", - result.context() - ); -} - -#[test] -fn ci_scopes_select_all_when_no_base() { - // `mergify ci scopes` with `--head HEAD` and no `--base` lists - // every configured scope as touched. Locally evaluated, no - // git, no API — the no-base branch is the one path through - // the command that doesn't shell out to `git diff`, so the - // test stays hermetic in the tmp cwd. - let tmp = tempfile::tempdir().expect("tempdir for config"); - let config_path = tmp.path().join("mergify.yml"); - std::fs::write( - &config_path, - "scopes:\n\ - \x20\x20source:\n\ - \x20\x20\x20\x20files:\n\ - \x20\x20\x20\x20\x20\x20backend:\n\ - \x20\x20\x20\x20\x20\x20\x20\x20include: ['mergify_cli/**']\n\ - \x20\x20\x20\x20\x20\x20frontend:\n\ - \x20\x20\x20\x20\x20\x20\x20\x20include: ['web/**']\n", - ) - .expect("write config"); - - let config_str = config_path.to_string_lossy().into_owned(); - let result = cli(&["ci", "scopes", "--config", &config_str, "--head", "HEAD"]); - assert_eq!(result.returncode, 0, "ci scopes failed{}", result.context()); - let combined = result.combined(); - for scope in &["backend", "frontend"] { - assert!( - combined.contains(scope), - "expected scope '{scope}' in the 'select all' output{}", - result.context() - ); - } -} - -// --------------------------------------------------------------- -// CI endpoints (CI-scoped token). -// --------------------------------------------------------------- - -#[test] -fn scopes_send_commit() { - // `PUT /v1/repos/{owner}/{repo}/commits/{sha}/scopes`, the route - // `scopes-send` takes whenever it can name the head. - // - // Worth its own live case precisely because the command falls back - // to the pull-request route on a 404: a misspelled path or an - // endpoint that never shipped would degrade silently and no - // offline test would notice. The SHA is arbitrary — the endpoint - // stores a report for a revision no open pull request heads, which - // it documents as a normal outcome — so this asserts auth and URL - // routing without depending on the canary repository's history. - let token = skip_if_unset!(live_token()); - - let result = cli(&[ - "ci", - "scopes-send", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--pull-request", - PULL_REQUEST, - "--head-sha", - "f00d5c0be5f00d5c0be5f00d5c0be5f00d5c0be5", - "--scope", - "func-tests-live-smoke", - ]); - assert_eq!( - result.returncode, - 0, - "scopes-send --head-sha failed{}", - result.context() - ); - // Exit 0 is also what a 404-and-fall-back produces, so the status - // alone would pass against the very absence this case exists to - // catch. The fallback narrates itself; require that it stayed quiet. - assert!( - !result - .stderr - .contains("commit scopes endpoint answered 404"), - "scopes-send fell back to the pull request route{}", - result.context() - ); -} - -#[test] -fn scopes_send() { - // `POST /v1/repos/{owner}/{repo}/pulls/{n}/scopes` — the fallback - // route, taken when no head SHA can be named. - let token = skip_if_unset!(live_token()); - - let result = cli(&[ - "ci", - "scopes-send", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--pull-request", - PULL_REQUEST, - "--scope", - "func-tests-live-smoke", - ]); - assert_eq!( - result.returncode, - 0, - "scopes-send failed{}", - result.context() - ); -} - -#[test] -fn junit_process() { - // OTLP traces upload + quarantine check round-trip. - // - // Uses a fixture with one failing test so the quarantine - // endpoint is actually called (`junit-process` short-circuits - // the quarantine call when the report has zero failures, - // which makes an all-passing fixture useless as a canary). - // Asserts on stdout rather than exit code: - // - // - `junit-process` swallows OTLP upload errors into a stdout - // warning ("reports not uploaded") without affecting the - // exit code, so a 5xx on `/ci/traces` would not surface as - // failure. - // - The exit code reflects whether failures are quarantined - // on the live tenant, which is a state the tests don't - // control. - // - // A green run is one where neither endpoint logged an error - // string into stdout. - let token = skip_if_unset!(live_token()); - - let junit_fixture = junit_fail_fixture(); - let junit_str = junit_fixture.to_string_lossy().into_owned(); - let result = cli(&[ - "ci", - "junit-process", - "--api-url", - API_URL, - "--token", - &token, - "--repository", - REPOSITORY, - "--tests-target-branch", - "main", - &junit_str, - ]); - - assert!( - !result.stdout.contains(" not uploaded"), - "OTLP traces endpoint did not accept upload{}", - result.context() - ); - assert!( - !result.stdout.contains("Failed to check quarantine"), - "quarantine endpoint did not respond{}", - result.context() - ); -} - -/// Path to the `JUnit` fixture used by [`junit_process`]. Lives -/// next to the test source so cargo packages it consistently. -fn junit_fail_fixture() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("fixtures") - .join("junit_fail.xml") -} diff --git a/crates/mergify-cli/tests/skill_references.rs b/crates/mergify-cli/tests/skill_references.rs deleted file mode 100644 index 832c9a33..00000000 --- a/crates/mergify-cli/tests/skill_references.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Cross-checks the `mergify-merge-queue` skill against the -//! freshly-built test binary. -//! -//! Replaces the pre-port Python `tests/queue/test_skill.py`: the -//! artifacts being validated (a Markdown skill file and the Rust -//! binary's `--list-native-commands` output) have no Python in -//! the picture, so the test lives next to the binary that emits -//! the truth. -//! -//! Each test fires the freshly-built binary via -//! `CARGO_BIN_EXE_mergify` — that's the same artifact `cargo test` -//! built moments earlier, so the test always exercises the -//! current code rather than whatever happens to be on `PATH`. - -use std::collections::BTreeSet; -use std::path::PathBuf; -use std::process::Command; - -use regex::Regex; -use serde_yaml_ng::Value; - -const REQUIRED_SECTIONS: &[&str] = &[ - "## Commands", - "## Checking Queue Status", - "## Inspecting a PR", - "## Queue States", - "## Troubleshooting", -]; - -/// Resolve `skills/mergify-merge-queue/SKILL.md` from the -/// repository root. `CARGO_MANIFEST_DIR` points at this crate's -/// directory; two `..` hops up to the workspace root. -fn skill_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("skills") - .join("mergify-merge-queue") - .join("SKILL.md") -} - -fn skill_content() -> String { - let path = skill_path(); - std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())) -} - -/// Ask the binary for its `(group, subcommand)` pairs and collect -/// the subcommands for `group`. Spawning the binary keeps the -/// test honest — a port that adds a native subcommand and its -/// `NATIVE_COMMANDS` entry shows up automatically, no parallel -/// list to drift. -fn native_commands_for_group(group: &str) -> BTreeSet { - let binary = env!("CARGO_BIN_EXE_mergify"); - let output = Command::new(binary) - .arg("--list-native-commands") - .output() - .unwrap_or_else(|e| panic!("spawn {binary} --list-native-commands: {e}")); - assert!( - output.status.success(), - "mergify --list-native-commands exited {:?}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); - stdout - .lines() - .filter_map(|line| { - let (g, sub) = line.split_once(char::is_whitespace)?; - (g == group).then(|| sub.to_string()) - }) - .collect() -} - -#[test] -fn skill_has_valid_frontmatter() { - let content = skill_content(); - // Extract YAML frontmatter between --- markers — the same - // shape Claude Code's skill loader expects. - let re = Regex::new(r"(?s)^---\n(.+?)\n---\n").expect("frontmatter regex compiles"); - let captures = re - .captures(&content) - .expect("Skill must have YAML frontmatter"); - let yaml = captures.get(1).unwrap().as_str(); - let parsed: Value = serde_yaml_ng::from_str(yaml).expect("frontmatter is valid YAML"); - let mapping = parsed - .as_mapping() - .expect("frontmatter must be a YAML mapping"); - let name = mapping - .get(Value::from("name")) - .and_then(Value::as_str) - .expect("frontmatter must have 'name'"); - assert_eq!(name, "mergify-merge-queue"); - assert!( - mapping.get(Value::from("description")).is_some(), - "frontmatter must have 'description'", - ); -} - -#[test] -fn skill_has_required_sections() { - let content = skill_content(); - for section in REQUIRED_SECTIONS { - assert!( - content.contains(section), - "Skill is missing required section: {section}", - ); - } -} - -#[test] -fn skill_references_valid_commands() { - let content = skill_content(); - let re = Regex::new(r"mergify queue ([\w-]+)").expect("reference regex compiles"); - // BTreeSet so iteration order — and therefore which assertion - // trips first — is deterministic. Same for `available` below: - // its `Debug` output ends up in the failure message and would - // otherwise reshuffle between runs. - let referenced: BTreeSet = re - .captures_iter(&content) - .map(|c| c[1].to_string()) - .collect(); - let available = native_commands_for_group("queue"); - - for cmd in &referenced { - assert!( - available.contains(cmd), - "Skill references 'mergify queue {cmd}' but it's not a Rust-native \ - command reported by `mergify --list-native-commands`. \ - Available: {available:?}", - ); - } -} diff --git a/crates/mergify-cli/tests/stack_drop.rs b/crates/mergify-cli/tests/stack_drop.rs deleted file mode 100644 index 35c5a578..00000000 --- a/crates/mergify-cli/tests/stack_drop.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! End-to-end tests for `mergify stack drop`. Spawns the -//! freshly-built binary against real git repos so the rebase -//! todo-rewrite self-invocation is exercised. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap_or_else(|e| panic!("spawn git -C {}: {args:?}: {e}", dir.display())) - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -fn count_log_lines(dir: &Path) -> usize { - capture(dir, &["log", "--format=%s", "origin/main..HEAD"]) - .lines() - .count() -} - -/// Three-commit stack `A → B → C` on a `feature` branch tracking -/// `origin/main`, every commit carrying a Change-Id trailer. -fn build_stack_repo() -> (tempfile::TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - let ok = isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap() - .success(); - assert!(ok); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid.to_string())); - } - (workdir, commits) -} - -fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .output() - .unwrap() -} - -#[test] -fn drop_removes_middle_commit() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - assert_eq!(count_log_lines(&local), 3); - - let output = run_mergify(&local, &["stack", "drop", &commits[1].0[..12]]); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let subjects = capture(&local, &["log", "--format=%s", "origin/main..HEAD"]); - let lines: Vec<&str> = subjects.lines().collect(); - // git log walks newest → oldest; with B dropped we expect - // [C, A]. - assert_eq!(lines, ["Commit C", "Commit A"]); -} - -#[test] -fn drop_removes_multiple_commits() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &["stack", "drop", &commits[0].0[..12], &commits[2].0[..12]], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - assert_eq!(count_log_lines(&local), 1); - assert_eq!(capture(&local, &["log", "-1", "--format=%s"]), "Commit B"); -} - -#[test] -fn drop_by_change_id() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify(&local, &["stack", "drop", &commits[1].1[..9]]); - assert!(output.status.success()); - - let subjects = capture(&local, &["log", "--format=%s", "origin/main..HEAD"]); - let lines: Vec<&str> = subjects.lines().collect(); - assert_eq!(lines, ["Commit C", "Commit A"]); -} - -#[test] -fn drop_dry_run_does_not_modify_history() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let head_before = capture(&local, &["rev-parse", "HEAD"]); - - let output = run_mergify(&local, &["stack", "drop", "--dry-run", &commits[1].0[..12]]); - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stdout).contains("Drop plan:"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout), - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains("Dry run"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout), - ); - - assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); -} - -#[test] -fn drop_unknown_prefix_exits_nonzero() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify(&local, &["stack", "drop", "deadbeef1234"]); - assert!(!output.status.success()); -} - -#[test] -fn drop_duplicate_prefix_is_rejected() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify( - &local, - &["stack", "drop", &commits[1].0[..7], &commits[1].0[..12]], - ); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr).contains("duplicate"), - "stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} diff --git a/crates/mergify-cli/tests/stack_edit.rs b/crates/mergify-cli/tests/stack_edit.rs deleted file mode 100644 index dac67d70..00000000 --- a/crates/mergify-cli/tests/stack_edit.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! End-to-end tests for `mergify stack edit`. -//! -//! These need the freshly-built `mergify` binary on disk to drive -//! `git rebase -i` through its `GIT_SEQUENCE_EDITOR` -//! self-invocation, so they live next to the binary crate where -//! `CARGO_BIN_EXE_mergify` is set by `cargo test`. The pure -//! pieces (commit prefix matching, rebase-todo rewriting) are -//! covered by unit tests in -//! `crates/mergify-stack/src/{commands/edit.rs,rebase_todo.rs}`. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// `CARGO_BIN_EXE_mergify` is set by `cargo test` for tests in -/// the crate that produces the binary. We assert it's present -/// because the rebase machinery only makes sense against the -/// real binary; falling back to PATH would pick up whichever -/// `mergify` is installed system-wide. -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap_or_else(|e| panic!("spawn git -C {}: {args:?}: {e}", dir.display())) - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// Bare upstream + local clone with three commits on `feature`, -/// each carrying a Change-Id trailer (the local-commits walker -/// requires it). Returned tuple: `(workdir, [(sha, change_id)])`. -fn build_stack_repo() -> (tempfile::TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - let ok = isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap() - .success(); - assert!(ok); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid.to_string())); - } - (workdir, commits) -} - -fn run_mergify_edit(local: &Path, target: &str) -> std::process::Output { - Command::new(mergify_binary()) - .args(["stack", "edit", target]) - .current_dir(local) - .output() - .unwrap() -} - -#[test] -fn stack_edit_pauses_at_target_commit() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify_edit(&local, &commits[1].0[..12]); - assert!( - output.status.success(), - "mergify stack edit failed: {:?}\nstdout: {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("Editing commit:")); - assert_eq!(capture(&local, &["log", "-1", "--format=%s"]), "Commit B"); - assert!(local.join(".git/rebase-merge").exists()); - - isolated_git() - .arg("-C") - .arg(&local) - .args(["rebase", "--abort"]) - .status() - .unwrap(); -} - -#[test] -fn stack_edit_pauses_by_change_id_prefix() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify_edit(&local, &commits[1].1[..9]); - assert!( - output.status.success(), - "mergify stack edit failed: {:?}\nstdout: {}\nstderr: {}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert_eq!(capture(&local, &["log", "-1", "--format=%s"]), "Commit B"); - - isolated_git() - .arg("-C") - .arg(&local) - .args(["rebase", "--abort"]) - .status() - .unwrap(); -} - -#[test] -fn stack_edit_unknown_prefix_exits_nonzero() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify_edit(&local, "deadbeef1234"); - assert!(!output.status.success(), "should have failed"); - assert!( - String::from_utf8_lossy(&output.stderr).contains("deadbeef1234"), - "stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} - -#[test] -fn stack_edit_empty_stack_prints_message() { - // Local clone where `feature` is at the same commit as `main` - // — no commits between trunk and HEAD. - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - - let output = run_mergify_edit(&local, "anything"); - assert!(output.status.success()); - assert!( - String::from_utf8_lossy(&output.stdout).contains("No commits in the stack"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout), - ); -} diff --git a/crates/mergify-cli/tests/stack_fixup.rs b/crates/mergify-cli/tests/stack_fixup.rs deleted file mode 100644 index a8597b08..00000000 --- a/crates/mergify-cli/tests/stack_fixup.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! End-to-end tests for `mergify stack fixup`. Spawns the -//! freshly-built binary so the rebase-todo-rewrite self-invocation -//! is exercised end to end. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap_or_else(|e| panic!("spawn git -C {}: {args:?}: {e}", dir.display())) - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// `feature` branch on top of `origin/main` with three real -/// commits — non-empty so git can actually fold them. -fn build_stack_repo() -> (tempfile::TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid.to_string())); - } - (workdir, commits) -} - -fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .output() - .unwrap() -} - -fn count_log_lines(dir: &Path) -> usize { - capture(dir, &["log", "--format=%s", "origin/main..HEAD"]) - .lines() - .count() -} - -#[test] -fn fixup_folds_commit_into_parent() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify(&local, &["stack", "fixup", &commits[1].0[..12]]); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - // B folded into A: stack now contains [A, C] (HEAD is C). - let subjects = capture(&local, &["log", "--format=%s", "origin/main..HEAD"]); - let lines: Vec<&str> = subjects.lines().collect(); - assert_eq!(lines, ["Commit C", "Commit A"]); -} - -#[test] -fn fixup_dry_run_does_not_modify_history() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let head_before = capture(&local, &["rev-parse", "HEAD"]); - - let output = run_mergify( - &local, - &["stack", "fixup", "--dry-run", &commits[1].0[..12]], - ); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("Fixup plan:")); - assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); - assert_eq!(count_log_lines(&local), 3); -} - -#[test] -fn fixup_rejects_first_commit() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify(&local, &["stack", "fixup", &commits[0].0[..12]]); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr).contains("first commit"), - "stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} - -#[test] -fn fixup_unknown_prefix_exits_nonzero() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify(&local, &["stack", "fixup", "deadbeef1234"]); - assert!(!output.status.success()); -} diff --git a/crates/mergify-cli/tests/stack_list.rs b/crates/mergify-cli/tests/stack_list.rs deleted file mode 100644 index 3b797cfa..00000000 --- a/crates/mergify-cli/tests/stack_list.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! End-to-end tests for `mergify stack list`. Spawns the real binary -//! against a wiremock GitHub server and a real git repo. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use wiremock::matchers::{method, path, query_param}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// `feature` branch with `n_commits` commits on top of a pushed -/// `main`, each carrying a Change-Id distinct in its first 8 hex — -/// the part a stack branch name carries. -fn build_stack_repo(n_commits: usize) -> (tempfile::TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - - let mut commits = Vec::new(); - for i in 0..n_commits { - let label = (b'A' + u8::try_from(i).expect("test stack stays under 26 commits")) as char; - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - let cid = format!("I{:08x}{}", i + 1, "0".repeat(32)); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - commits.push((capture(&local, &["rev-parse", "HEAD"]), cid)); - } - (workdir, commits) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_reads_mergeable_from_the_pull_request_itself() { - // Stack discovery goes through the pull-request *list* endpoint, - // which never carries `mergeable`. `stack list` has to ask the PR - // for it, or it could no longer flag a conflicting one. - let (work, commits) = build_stack_repo(2); - let local = work.path().join("local"); - let head_ref = format!("stack/tester/feature/commit--{}", &commits[0].1[1..9]); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path( - "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/", - )) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(serde_json::json!([{"ref": format!("refs/heads/{head_ref}")}])), - ) - .expect(1) - .mount(&server) - .await; - let listed = serde_json::json!({ - "number": 101, - "state": "open", - "draft": false, - "merged_at": null, - "title": "Commit A", - "user": {"login": "tester"}, - "updated_at": "2026-01-01T00:00:00Z", - "head": {"ref": head_ref, "sha": commits[0].0}, - "base": {"ref": "main"}, - "html_url": "https://github.com/myorg/myrepo/pull/101", - }); - Mock::given(method("GET")) - .and(path("/repos/myorg/myrepo/pulls")) - .and(query_param("head", format!("myorg:{head_ref}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([listed]))) - .expect(1) - .mount(&server) - .await; - // Commit B has no PR yet: its branch is looked up and holds none. - Mock::given(method("GET")) - .and(path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .with_priority(10) - .expect(1) - .mount(&server) - .await; - let mut full = listed.clone(); - full["mergeable"] = serde_json::json!(false); - Mock::given(method("GET")) - .and(path("/repos/myorg/myrepo/pulls/101")) - .respond_with(ResponseTemplate::new(200).set_body_json(full)) - .expect(1) - .mount(&server) - .await; - mount_no_checks_or_reviews(&server, &commits[0].0, 101).await; - - let output = run_list(&local, &server.uri()); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - let entries: Vec<_> = payload["entries"] - .as_array() - .unwrap() - .iter() - .map(|e| { - ( - e["pull_number"].clone(), - e["status"].clone(), - e["mergeable"].clone(), - ) - }) - .collect(); - assert_eq!( - entries, - vec![ - ( - serde_json::json!(101), - serde_json::json!("open"), - serde_json::json!(false) - ), - ( - serde_json::Value::Null, - serde_json::json!("no_pr"), - serde_json::Value::Null - ), - ], - ); -} - -fn run_list(local: &Path, server_uri: &str) -> std::process::Output { - Command::new(mergify_binary()) - .args([ - "stack", - "list", - "--json", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - // Bypass slug discovery — `origin` is a local tempdir path. - "--repository", - "myorg/myrepo", - ]) - .current_dir(local) - .env("MERGIFY_TOKEN", "test-token") - .env("MERGIFY_GITHUB_SERVER", server_uri) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .output() - .unwrap() -} - -/// The CI and review columns: nothing to report. -async fn mount_no_checks_or_reviews(server: &MockServer, head_sha: &str, number: u64) { - Mock::given(method("GET")) - .and(path(format!( - "/repos/myorg/myrepo/commits/{head_sha}/check-runs" - ))) - .respond_with( - ResponseTemplate::new(200).set_body_json(serde_json::json!({"check_runs": []})), - ) - .mount(server) - .await; - Mock::given(method("GET")) - .and(path(format!("/repos/myorg/myrepo/pulls/{number}/reviews"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(server) - .await; -} diff --git a/crates/mergify-cli/tests/stack_push_github_native.rs b/crates/mergify-cli/tests/stack_push_github_native.rs deleted file mode 100644 index 2f0feff2..00000000 --- a/crates/mergify-cli/tests/stack_push_github_native.rs +++ /dev/null @@ -1,1117 +0,0 @@ -//! End-to-end tests for `mergify stack push`'s GitHub-native stack -//! registration, on by default and disabled with `--no-github-native`. -//! -//! Runs the real binary against a wiremock GitHub server and a real -//! git repo, and asserts on the *sequence* of requests it issued — -//! because the whole feature is a sequencing contract: -//! -//! - with `--no-github-native`, not a single `/stacks` request may be -//! sent (the disabled path has to stay exactly what it was before -//! this feature existed); -//! - a push that only refreshes commits must leave the registration -//! completely alone — no unstack, no re-registration, and no `base` -//! in the PATCH bodies, which is what makes that possible; -//! - a push that adds changes on top must *extend* the stack rather -//! than rebuild it; -//! - a push that retargets a pull request must `unstack` **before** -//! the first PR mutation and `POST /stacks` **after** the last one. -//! Getting that order wrong is what permanently closes a surviving -//! pull request (see `mergify_stack::native_stack`), and no unit -//! test on the module in isolation can catch it; -//! - the two surfaces keyed off the registration's outcome — the -//! `Depends-On:` header and the sticky stack comment — must follow -//! the outcome and not the flag, in both directions. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use wiremock::matchers::{method, path as wm_path, path_regex, query_param}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -/// A `feature` branch with `n_commits` Change-Id-carrying commits on -/// top of a pushed `main`, plus a bare `origin` the push can reach. -fn build_stack_repo(n_commits: usize) -> (tempfile::TempDir, Vec) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - - let mut change_ids = Vec::new(); - for i in 0..n_commits { - let label = (b'A' + u8::try_from(i).expect("test stack stays under 26 commits")) as char; - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - // Distinct in the first 8 hex — that prefix is what a stack - // branch segment carries, and a collision would make the two - // commits look like one change. - let cid = format!("I{:08x}{}", i + 1, "0".repeat(32)); - run_in( - &local, - &[ - "commit", - "-q", - "-m", - &format!("Commit {label}\n\nChange-Id: {cid}"), - ], - ); - change_ids.push(cid); - } - (workdir, change_ids) -} - -/// Mock the stack discovery a push starts with: `pulls` are the PRs -/// already open, each listed as a live branch under the stack prefix -/// and returned by the lookup on its own `head.ref`. Any other lookup -/// — a local commit with no PR yet — finds nothing. Each PR is given -/// the `user.login` the push filters on (`--author tester`). -async fn mount_discovery(server: &MockServer, pulls: &[serde_json::Value]) { - let head_ref = |pull: &serde_json::Value| pull["head"]["ref"].as_str().unwrap().to_string(); - let refs: Vec<_> = pulls - .iter() - .map(|pull| serde_json::json!({"ref": format!("refs/heads/{}", head_ref(pull))})) - .collect(); - Mock::given(method("GET")) - .and(wm_path( - "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(refs)) - .mount(server) - .await; - for pull in pulls { - let mut listed = pull.clone(); - listed["user"] = serde_json::json!({"login": "tester"}); - Mock::given(method("GET")) - .and(wm_path("/repos/myorg/myrepo/pulls")) - .and(query_param("head", format!("myorg:{}", head_ref(pull)))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([listed]))) - .mount(server) - .await; - } - Mock::given(method("GET")) - .and(wm_path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .with_priority(10) - .mount(server) - .await; -} - -/// Mock GitHub for a push that creates `pr_numbers.len()` brand-new -/// PRs: nothing on the remote yet, a POST that hands out the numbers -/// in order, and the comment endpoints the stack comment needs. -async fn mock_github_creating(pr_numbers: &[u64]) -> MockServer { - let server = MockServer::start().await; - - mount_discovery(&server, &[]).await; - - // One POST mock per PR, mounted newest-first so wiremock's - // last-mounted-wins ordering hands out the numbers bottom-to-top - // as the sequential upsert loop asks for them. - for (i, number) in pr_numbers.iter().enumerate() { - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ - "number": number, - "state": "open", - "merged_at": null, - "title": format!("Commit {}", (b'A' + u8::try_from(i).unwrap()) as char), - "head": {"ref": format!("head-{number}"), "sha": "0".repeat(40)}, - "base": {"ref": "main"}, - "html_url": format!("https://github.com/myorg/myrepo/pull/{number}"), - }))) - .up_to_n_times(1) - .mount(&server) - .await; - } - - // The push never patches a pull request it just created — this - // mock is here for the one thing that does: writing a - // `Depends-On:` header back after a registration that did not - // happen. - Mock::given(method("PATCH")) - .and(path_regex(r"^/repos/myorg/myrepo/pulls/\d+$")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) - .mount(&server) - .await; - - // Stack comments: none exist, so each PR gets a POST. - Mock::given(method("GET")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1}))) - .mount(&server) - .await; - - server -} - -/// Mock GitHub for a push over two PRs that already exist **and are -/// already members of native stack #7**. Both PR payloads carry the -/// `stack` object exactly as GitHub puts it on the default API version. -/// -/// `top_base` is the base branch GitHub reports for the top PR: pass -/// `bottom_ref` for a stack that is correctly chained (the routine -/// push, which must not touch the registration) or anything else for -/// one the planner has to retarget (the case that needs the fence). -/// -/// The `/stacks` endpoints are deliberately *not* mounted here — every -/// test mounts the ones it expects, and asserts on the request log -/// rather than on mock absence: an unmounted `/stacks` call 404s, and -/// both `unstack` and `register` treat a 404 as a non-event by design. -async fn mock_github_updating_a_stacked_pair( - bottom_ref: &str, - top_ref: &str, - top_base: &str, - head_sha: &str, -) -> MockServer { - let server = MockServer::start().await; - let pulls: Vec<_> = [(101_u64, bottom_ref, "main"), (102, top_ref, top_base)] - .iter() - .enumerate() - .map(|(i, (number, head, base))| { - serde_json::json!({ - "number": number, - "state": "open", - "merged_at": null, - "draft": false, - "title": "existing", - "body": "existing", - "head": {"ref": head, "sha": head_sha}, - "base": {"ref": base}, - "html_url": format!("https://github.com/myorg/myrepo/pull/{number}"), - "stack": {"id": 162_170, "number": 7, "position": i + 1, "size": 2}, - }) - }) - .collect(); - mount_discovery(&server, &pulls).await; - for number in [101_u64, 102] { - Mock::given(method("GET")) - .and(wm_path(format!( - "/repos/myorg/myrepo/pulls/{number}/reviews" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(&server) - .await; - Mock::given(method("PATCH")) - .and(wm_path(format!("/repos/myorg/myrepo/pulls/{number}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) - .mount(&server) - .await; - } - Mock::given(method("GET")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1}))) - .mount(&server) - .await; - server -} - -fn run_push(local: &Path, server_uri: &str, extra: &[&str]) -> std::process::Output { - let mut args = vec![ - "stack", - "push", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - // Bypass slug discovery — `origin` is a local tempdir path. - "--repo", - "myorg/myrepo", - // Keep the test hermetic: no rebase round-trips, no - // revision-history comments (Creates have no history anyway). - "--skip-rebase", - "--no-revision-history", - ]; - args.extend_from_slice(extra); - Command::new(mergify_binary()) - .args(&args) - .current_dir(local) - .env("MERGIFY_TOKEN", "test-token") - .env("MERGIFY_GITHUB_SERVER", server_uri) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .output() - .unwrap() -} - -fn assert_success(output: &std::process::Output) { - assert!( - output.status.success(), - "push failed\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -/// `METHOD /path` for each request the server saw, in order. -async fn request_log(server: &MockServer) -> Vec { - server - .received_requests() - .await - .unwrap() - .iter() - .map(|r| format!("{} {}", r.method.as_str(), r.url.path())) - .collect() -} - -/// Asserts the escape hatch actually took: no `/stacks` request at -/// all, and the `Depends-On:` chain written exactly as it always was. -/// Shared by the two ways to reach it — the flag and git config. -async fn assert_stacks_untouched_and_depends_on_chained(server: &MockServer) { - let log = request_log(server).await; - assert!( - log.iter().all(|r| !r.contains("/stacks")), - "the escape hatch must not touch the Stacks API, got: {log:#?}", - ); - let bodies = pull_post_bodies(server).await; - assert_eq!(bodies.len(), 2, "two PRs are created: {bodies:#?}"); - assert!( - bodies[1]["body"] - .as_str() - .unwrap() - .contains("Depends-On: #101"), - "the upper PR must chain to the lower one, got: {}", - bodies[1], - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn no_github_native_keeps_the_stacks_api_untouched() { - // The load-bearing regression test for the escape hatch: passing - // `--no-github-native` must reproduce the pre-native-stack - // behaviour exactly, byte for byte. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - - assert_success(&run_push(&local, &server.uri(), &["--no-github-native"])); - - assert_stacks_untouched_and_depends_on_chained(&server).await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn git_config_false_keeps_the_stacks_api_untouched_without_the_flag() { - // The other half of the escape hatch: a repo that has opted out in - // git config must stay opted out with no flag at all. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - run_in( - &local, - &["config", "mergify-cli.stack-github-native", "false"], - ); - let server = mock_github_creating(&[101, 102]).await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - assert_stacks_untouched_and_depends_on_chained(&server).await; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn by_default_depends_on_is_omitted_when_native_registration_succeeds() { - // The point of the feature, unset config and no flag: once GitHub - // holds the order, the header is a second copy of it, in prose, - // that a reader has to reconcile against the first. One edge, one - // source of truth. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 12}))) - .mount(&server) - .await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - let descriptions = written_descriptions(&server).await; - assert_eq!( - descriptions.len(), - 2, - "two PRs are created: {descriptions:#?}" - ); - for description in &descriptions { - assert!( - !description.contains("Depends-On"), - "a registered stack needs no marker, got: {description:?}", - ); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn depends_on_restored_when_native_registration_does_not_happen() { - // The whole reason the removal is keyed off the registration and - // not off the flag: registration degrades silently by design, and - // a stack that is neither registered on GitHub nor chained by - // markers has no ordering anywhere: Mergify would let a mid-stack - // pull request merge ahead of the one below it. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - // The repair runs after the failed registration, and only for the - // pull request that actually has a predecessor. - let log = request_log(&server).await; - let register = log - .iter() - .position(|r| r == "POST /repos/myorg/myrepo/stacks") - .expect("registration is attempted"); - let repair = log - .iter() - .position(|r| r == "PATCH /repos/myorg/myrepo/pulls/102") - .unwrap_or_else(|| panic!("no repair PATCH in {log:#?}")); - assert!( - repair > register, - "repair must follow the verdict: {log:#?}" - ); - assert!( - !log.contains(&"PATCH /repos/myorg/myrepo/pulls/101".to_string()), - "the bottom PR has no predecessor to restore: {log:#?}", - ); - - let patched = pull_patch_bodies(&server).await; - assert_eq!(patched.len(), 1, "one body is rewritten: {patched:#?}"); - assert!( - patched[0]["body"] - .as_str() - .unwrap() - .contains("Depends-On: #101"), - "the marker must come back pointing at the predecessor, got: {}", - patched[0], - ); - // Body only: a `base` key is what a stacked PR 422s on, and the - // repair has no business retargeting anything. - assert!( - patched[0].get("base").is_none() && patched[0].get("title").is_none(), - "the repair must touch nothing but the body, got: {}", - patched[0], - ); - - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!( - out.contains("Depends-On"), - "the fallback should be stated, got: {out}", - ); -} - -/// The `url` GitHub puts on an issue comment, as an absolute URL — -/// which is what the CLI has to turn back into a path before it can -/// call the API with it. -fn comment_url(server: &MockServer, id: u64) -> String { - format!("{}/repos/myorg/myrepo/issues/comments/{id}", server.uri()) -} - -/// Every `METHOD /path` the server saw against the issue-comments -/// endpoints, which is where both stack-comment surfaces live. -async fn comment_requests(server: &MockServer) -> Vec { - request_log(server) - .await - .into_iter() - .filter(|r| r.contains("/issues/") && r.contains("comments")) - .collect() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn no_stack_comment_is_posted_when_native_registration_succeeds() { - // GitHub's own Stacks UI lists the members on the pull request - // page, so the sticky table is a second copy of what the reader is - // already looking at — the same argument as the `Depends-On:` - // header one surface up. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 12}))) - .mount(&server) - .await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - let comments = comment_requests(&server).await; - assert!( - comments.iter().all(|r| r.starts_with("GET ")), - "a registered stack must have nothing written to its comments, got: {comments:#?}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_stack_comment_from_an_older_push_is_deleted_once_github_holds_the_stack() { - // The migration half. Every stack pushed before this behaviour - // shipped carries the table, and nothing refreshes it any more, so - // it would sit frozen at that push's membership under a live list - // that keeps moving. Take it down on the next push instead. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - // Outranks `mock_github_creating`'s empty listing: wiremock breaks - // a tie between two matching mocks by mount order, and 1 beats the - // default 5 regardless of it. - Mock::given(method("GET")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ - {"url": comment_url(&server, 900), "body": "looks good to me"}, - { - "url": comment_url(&server, 901), - "body": "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\n| # | Pull Request | Link | |\n", - }, - ]))) - .with_priority(1) - .mount(&server) - .await; - // Only ours is deletable: a DELETE aimed at comment 900 would 404 - // here and fail the push. - Mock::given(method("DELETE")) - .and(wm_path("/repos/myorg/myrepo/issues/comments/901")) - .respond_with(ResponseTemplate::new(204)) - .expect(2) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 12}))) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let comments = comment_requests(&server).await; - assert!( - comments.iter().all(|r| !r.starts_with("POST ")), - "the comment is taken down, never rewritten, got: {comments:#?}", - ); - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!( - out.contains("removed 2 stack comments"), - "the cleanup should be stated, got: {out}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_registered_stack_down_to_one_live_pull_request_still_loses_its_comment() { - // The stale-comment case the member count would hide. A stack - // whose members merge down to one open pull request keeps its - // registration — `appendable_tail` finds nothing to add, so GitHub - // goes on holding stack #7 — and that last pull request is still - // carrying the table it was given while the stack had several - // members. Counting pull requests before deciding to look would - // skip exactly this one, leaving a frozen list under GitHub's live - // one for ever. - // - // The count still gates the fallback upsert, and that costs - // nothing here: GitHub rejects a stack below two members, so a - // genuinely one-change stack is never registered and never reaches - // the removal at all. - let (_work, local, change_ids, remote_head) = repo_with_pushed_branches(1, 1); - let server = MockServer::start().await; - mount_discovery( - &server, - &[serde_json::json!({ - "number": 101, - "state": "open", - "merged_at": null, - "draft": false, - "title": "existing", - "body": "existing", - "head": {"ref": head_ref(&change_ids, 0), "sha": remote_head}, - "base": {"ref": "main"}, - "html_url": "https://github.com/myorg/myrepo/pull/101", - // What is left of a stack that was bigger: GitHub keeps - // the registration and infers the merged prefix itself. - "stack": {"id": 162_170, "number": 7, "position": 1, "size": 1}, - })], - ) - .await; - Mock::given(method("GET")) - .and(wm_path("/repos/myorg/myrepo/pulls/101/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(&server) - .await; - Mock::given(method("PATCH")) - .and(wm_path("/repos/myorg/myrepo/pulls/101")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path_regex(r"^/repos/myorg/myrepo/issues/\d+/comments$")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{ - "url": comment_url(&server, 901), - "body": "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\n| # | Pull Request | Link | |\n", - }]))) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(wm_path("/repos/myorg/myrepo/issues/comments/901")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!( - out.contains("GitHub stack #7 unchanged"), - "the registration must survive the shrink, or this is not the \ - case under test, got: {out}", - ); - assert!( - out.contains("removed 1 stack comment"), - "the last member's stale table is taken down, got: {out}", - ); - // A POST here would be the opposite failure: writing the table - // back onto a pull request GitHub is already listing. - let comments = comment_requests(&server).await; - assert!( - comments.iter().all(|r| !r.starts_with("POST ")), - "nothing is written back, got: {comments:#?}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn the_stack_comment_stays_when_native_registration_does_not_happen() { - // The other direction, and the reason this is keyed off the - // outcome rather than the flag: a push that degraded has GitHub - // rendering nothing, so dropping the comment would leave the stack - // with no member list anywhere. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) - .mount(&server) - .await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - let comments = comment_requests(&server).await; - assert_eq!( - comments - .iter() - .filter(|r| r.starts_with("POST ") && r.ends_with("/comments")) - .count(), - 2, - "both pull requests keep the table, got: {comments:#?}", - ); - assert!( - comments.iter().all(|r| !r.starts_with("DELETE ")), - "nothing is taken down when nothing replaced it, got: {comments:#?}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn by_default_the_stack_is_registered_after_every_pull_request_is_upserted() { - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 12}))) - .expect(1) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let log = request_log(&server).await; - let register = log - .iter() - .position(|r| r == "POST /repos/myorg/myrepo/stacks") - .unwrap_or_else(|| panic!("no stack registration in {log:#?}")); - let last_create = log - .iter() - .rposition(|r| r == "POST /repos/myorg/myrepo/pulls") - .expect("PRs are created"); - assert!( - register > last_create, - "the stack must be registered only once every PR exists, got: {log:#?}", - ); - - // Members are sent bottom-to-top, as JSON integers. - let body: serde_json::Value = server - .received_requests() - .await - .unwrap() - .iter() - .find(|r| r.url.path() == "/repos/myorg/myrepo/stacks" && r.method.as_str() == "POST") - .map(|r| serde_json::from_slice(&r.body).unwrap()) - .unwrap(); - assert_eq!(body, serde_json::json!({"pull_requests": [101, 102]})); - - // And the user is told, without it looking like a failure. - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!(out.contains("GitHub stack #12"), "output was: {out}"); -} - -/// Stack-branch name `mergify stack push` derives for commit `i` of a -/// repo built by [`build_stack_repo`]. -fn head_ref(change_ids: &[String], i: usize) -> String { - format!( - "stack/tester/feature/commit-{}--{}", - (b'a' + u8::try_from(i).unwrap()) as char, - &change_ids[i][1..9], - ) -} - -/// A repo of `n_commits` whose first `n_existing` stack branches are -/// already on the remote, parked on trunk. -/// -/// Those commits then read as Updates of pull requests that already -/// exist — the branches must really be there at the SHA the mocked -/// payloads claim, or the push's force-with-lease rejects them, and -/// parking them on trunk (rather than at the local commit) is what -/// keeps them from planning as up-to-date and issuing no PATCH at all. -fn repo_with_pushed_branches( - n_commits: usize, - n_existing: usize, -) -> (tempfile::TempDir, PathBuf, Vec, String) { - let (work, change_ids) = build_stack_repo(n_commits); - let local = work.path().join("local"); - let remote_head = capture(&local, &["rev-parse", "origin/main"]); - for i in 0..n_existing { - run_in( - &local, - &[ - "push", - "-q", - "origin", - &format!("{remote_head}:refs/heads/{}", head_ref(&change_ids, i)), - ], - ); - } - (work, local, change_ids, remote_head) -} - -/// Bodies of every `PATCH /repos/myorg/myrepo/pulls/{n}` the server -/// saw, in order. -async fn pull_patch_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .unwrap() - .iter() - .filter(|r| { - r.method.as_str() == "PATCH" && r.url.path().starts_with("/repos/myorg/myrepo/pulls/") - }) - .map(|r| serde_json::from_slice(&r.body).unwrap()) - .collect() -} - -/// Bodies of every `POST /repos/myorg/myrepo/pulls` the server saw, -/// in order — i.e. the pull requests this push created. -async fn pull_post_bodies(server: &MockServer) -> Vec { - server - .received_requests() - .await - .unwrap() - .iter() - .filter(|r| r.method.as_str() == "POST" && r.url.path() == "/repos/myorg/myrepo/pulls") - .map(|r| serde_json::from_slice(&r.body).unwrap()) - .collect() -} - -/// The rendered PR descriptions this push wrote, in request order, -/// whether by creating a pull request or by patching one. -async fn written_descriptions(server: &MockServer) -> Vec { - server - .received_requests() - .await - .unwrap() - .iter() - .filter(|r| { - matches!(r.method.as_str(), "POST" | "PATCH") - && r.url.path().starts_with("/repos/myorg/myrepo/pulls") - }) - .filter_map(|r| { - let body: serde_json::Value = serde_json::from_slice(&r.body).ok()?; - Some(body.get("body")?.as_str()?.to_string()) - }) - .collect() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_routine_push_leaves_the_registration_completely_alone() { - // The answer to "does this churn the API on every push?": no. The - // stack lock is only about `base`, so a push that just refreshes - // commits sends no `base`, and therefore needs no unstack, no - // re-registration — no `/stacks` request at all. The PRs keep - // their stack number and watchers see no new `pull_request.stacked` - // events. - let (_work, local, change_ids, remote_head) = repo_with_pushed_branches(2, 2); - let server = mock_github_updating_a_stacked_pair( - &head_ref(&change_ids, 0), - &head_ref(&change_ids, 1), - // Already correctly chained: nothing is being retargeted. - &head_ref(&change_ids, 0), - &remote_head, - ) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let log = request_log(&server).await; - assert!( - log.iter().all(|r| !r.contains("/stacks")), - "a push that only refreshes commits must not touch the \ - registration, got: {log:#?}", - ); - // What makes that safe: no `base` key in the update bodies. - let bodies = pull_patch_bodies(&server).await; - assert_eq!(bodies.len(), 2, "both PRs are updated: {bodies:#?}"); - for body in &bodies { - assert!( - body.get("base").is_none(), - "an unchanged base must not be sent — GitHub 422s the whole \ - PATCH while the PR is stacked, got: {body}", - ); - assert!( - !body["body"].as_str().unwrap().contains("Depends-On"), - "stack #7 still holds the order, so no marker is written: {body}", - ); - } - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!(out.contains("GitHub stack #7 unchanged"), "output: {out}"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_change_pushed_on_top_extends_the_stack_instead_of_rebuilding_it() { - // Appending is the other common stack operation, and GitHub has an - // endpoint for it. One `POST /stacks/7/add` keeps stack #7 — its - // number, its webhooks, its members' registration — where a - // dissolve + re-register would replace all of it. - let (_work, local, change_ids, remote_head) = repo_with_pushed_branches(3, 2); - let server = mock_github_updating_a_stacked_pair( - &head_ref(&change_ids, 0), - &head_ref(&change_ids, 1), - &head_ref(&change_ids, 0), - &remote_head, - ) - .await; - // The third commit has no PR yet. - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ - "number": 103, - "state": "open", - "merged_at": null, - "title": "Commit C", - "head": {"ref": head_ref(&change_ids, 2), "sha": "0".repeat(40)}, - "base": {"ref": head_ref(&change_ids, 1)}, - "html_url": "https://github.com/myorg/myrepo/pull/103", - }))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks/7/add")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"number": 7}))) - .expect(1) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let log = request_log(&server).await; - assert!( - !log.iter().any(|r| r.ends_with("/stacks/7/unstack")), - "extending must not dissolve the stack, got: {log:#?}", - ); - assert!( - !log.iter().any(|r| r == "POST /repos/myorg/myrepo/stacks"), - "extending must not re-register the stack, got: {log:#?}", - ); - // Only the new PR is appended, and only once every PR exists. - let add = log - .iter() - .position(|r| r == "POST /repos/myorg/myrepo/stacks/7/add") - .unwrap_or_else(|| panic!("no append in {log:#?}")); - let last_create = log - .iter() - .rposition(|r| r == "POST /repos/myorg/myrepo/pulls") - .expect("the new PR is created"); - assert!(add > last_create, "append after the create, got: {log:#?}"); - let body: serde_json::Value = server - .received_requests() - .await - .unwrap() - .iter() - .find(|r| r.url.path() == "/repos/myorg/myrepo/stacks/7/add") - .map(|r| serde_json::from_slice(&r.body).unwrap()) - .unwrap(); - assert_eq!(body, serde_json::json!({"pull_requests": [103]})); - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!(out.contains("added to GitHub stack #7"), "output: {out}"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_stack_that_cannot_be_extended_is_rebuilt() { - // The append is best effort: a 422 (the new PR doesn't chain onto - // the current top) or a 404 (someone dissolved the stack in the - // meantime) must leave a registration that describes reality, not - // a stale one. Safe to repair here because the mutations are done. - let (_work, local, change_ids, remote_head) = repo_with_pushed_branches(3, 2); - let server = mock_github_updating_a_stacked_pair( - &head_ref(&change_ids, 0), - &head_ref(&change_ids, 1), - &head_ref(&change_ids, 0), - &remote_head, - ) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ - "number": 103, - "state": "open", - "merged_at": null, - "title": "Commit C", - "head": {"ref": head_ref(&change_ids, 2), "sha": "0".repeat(40)}, - "base": {"ref": head_ref(&change_ids, 1)}, - "html_url": "https://github.com/myorg/myrepo/pull/103", - }))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks/7/add")) - .respond_with(ResponseTemplate::new(422).set_body_string("nope")) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks/7/unstack")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 14}))) - .expect(1) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - - let body: serde_json::Value = server - .received_requests() - .await - .unwrap() - .iter() - .find(|r| r.url.path() == "/repos/myorg/myrepo/stacks" && r.method.as_str() == "POST") - .map(|r| serde_json::from_slice(&r.body).unwrap()) - .unwrap(); - assert_eq!(body, serde_json::json!({"pull_requests": [101, 102, 103]})); - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!(out.contains("GitHub stack #14"), "output: {out}"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_registered_stack_is_dissolved_before_a_pull_request_is_retargeted() { - // The fence. GitHub rejects `PATCH /pulls/{n}` carrying `base` - // while the PR is stacked — and the failed retarget is what lets - // the orphan teardown close a surviving PR for good. So when this - // push does move a base, the unstack has to precede every - // PR-mutating call, not just the `neutralize_stale_bases` one. - let (_work, local, change_ids, remote_head) = repo_with_pushed_branches(2, 2); - let server = mock_github_updating_a_stacked_pair( - &head_ref(&change_ids, 0), - &head_ref(&change_ids, 1), - // Top PR sits on a branch that is no longer its predecessor — - // the shape a reorder leaves behind. The push must retarget it. - "stack/tester/feature/commit-z--Ideadbee", - &remote_head, - ) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks/7/unstack")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 13}))) - .expect(1) - .mount(&server) - .await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - let log = request_log(&server).await; - let unstack = log - .iter() - .position(|r| r == "POST /repos/myorg/myrepo/stacks/7/unstack") - .unwrap_or_else(|| panic!("no unstack in {log:#?}")); - let first_mutation = log - .iter() - .position(|r| r.starts_with("PATCH /repos/myorg/myrepo/pulls/")) - .unwrap_or_else(|| panic!("no PR update in {log:#?}")); - let register = log - .iter() - .position(|r| r == "POST /repos/myorg/myrepo/stacks") - .unwrap_or_else(|| panic!("no re-registration in {log:#?}")); - assert!( - unstack < first_mutation, - "unstack must precede every PR mutation, got: {log:#?}", - ); - assert!( - register > first_mutation, - "re-registration must follow the mutations, got: {log:#?}", - ); - // And the retarget really is sent — otherwise this test would pass - // for the wrong reason. - let bodies = pull_patch_bodies(&server).await; - assert!( - bodies.iter().any(|b| b.get("base").is_some()), - "the moving PR must carry `base`, got: {bodies:#?}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_repository_without_the_stacks_api_still_pushes_cleanly() { - // Old GHES, or a repo where the feature isn't enabled: the PRs - // are all correct, so a 404 on registration must not colour the - // exit code or read as an error. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) - .mount(&server) - .await; - - let output = run_push(&local, &server.uri(), &[]); - assert_success(&output); - let out = String::from_utf8_lossy(&output.stdout) + String::from_utf8_lossy(&output.stderr); - assert!( - out.contains("not registered on GitHub"), - "the skip should be stated plainly, got: {out}", - ); - assert!( - !out.contains("mergify: "), - "degrading must not print an error, got: {out}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_one_change_stack_stays_a_plain_pull_request() { - // GitHub rejects a 1-PR stack with 422, so we don't even ask. - let (work, _) = build_stack_repo(1); - let local = work.path().join("local"); - let server = mock_github_creating(&[101]).await; - - assert_success(&run_push(&local, &server.uri(), &[])); - - let log = request_log(&server).await; - assert!( - log.iter().all(|r| !r.contains("/stacks")), - "below the 2-PR floor nothing should be sent, got: {log:#?}", - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn git_config_true_is_equivalent_to_the_unset_default() { - // Not load-bearing on its own now that `true` is the default, but - // worth pinning: a repo that pins the config explicitly must see - // the exact same behaviour as one that never mentions it. - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - run_in( - &local, - &["config", "mergify-cli.stack-github-native", "true"], - ); - let server = mock_github_creating(&[101, 102]).await; - Mock::given(method("POST")) - .and(wm_path("/repos/myorg/myrepo/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"number": 12}))) - .expect(1) - .mount(&server) - .await; - - assert_success(&run_push(&local, &server.uri(), &[])); -} diff --git a/crates/mergify-cli/tests/stack_reorder.rs b/crates/mergify-cli/tests/stack_reorder.rs deleted file mode 100644 index dfad9a8a..00000000 --- a/crates/mergify-cli/tests/stack_reorder.rs +++ /dev/null @@ -1,228 +0,0 @@ -//! End-to-end tests for `mergify stack reorder` and -//! `mergify stack move` — both reduce to the same -//! `Action::Reorder` machinery, so they share a test binary. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// `feature` branch with three non-empty commits A, B, C -/// (touching different files so reorder is conflict-free). -fn build_stack_repo() -> (tempfile::TempDir, Vec) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - commits.push(capture(&local, &["rev-parse", "HEAD"])); - } - (workdir, commits) -} - -fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .output() - .unwrap() -} - -fn feature_subjects(local: &Path) -> Vec { - capture( - local, - &["log", "--reverse", "--format=%s", "origin/main..HEAD"], - ) - .lines() - .map(str::to_string) - .collect() -} - -#[test] -fn reorder_to_explicit_sequence() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &[ - "stack", - "reorder", - &commits[2][..12], // C - &commits[0][..12], // A - &commits[1][..12], // B - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let order = feature_subjects(&local); - assert_eq!(order, ["Commit C", "Commit A", "Commit B"]); -} - -#[test] -fn reorder_already_in_order_is_noop() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let head_before = capture(&local, &["rev-parse", "HEAD"]); - - let output = run_mergify( - &local, - &[ - "stack", - "reorder", - &commits[0][..12], - &commits[1][..12], - &commits[2][..12], - ], - ); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("already")); - assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); -} - -#[test] -fn reorder_count_mismatch_errors() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify(&local, &["stack", "reorder", &commits[0][..12]]); - assert!(!output.status.success()); -} - -#[test] -fn move_commit_first_brings_it_to_the_top() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify(&local, &["stack", "move", &commits[2][..12], "first"]); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - assert_eq!( - feature_subjects(&local), - ["Commit C", "Commit A", "Commit B"] - ); -} - -#[test] -fn move_commit_before_target() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &[ - "stack", - "move", - &commits[2][..12], // C - "before", - &commits[0][..12], // A - ], - ); - assert!(output.status.success()); - assert_eq!( - feature_subjects(&local), - ["Commit C", "Commit A", "Commit B"] - ); -} - -#[test] -fn move_before_without_target_errors() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify(&local, &["stack", "move", &commits[2][..12], "before"]); - assert!(!output.status.success()); -} - -#[test] -fn move_first_with_target_errors() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify( - &local, - &[ - "stack", - "move", - &commits[2][..12], - "first", - &commits[0][..12], - ], - ); - assert!(!output.status.success()); -} diff --git a/crates/mergify-cli/tests/stack_reword.rs b/crates/mergify-cli/tests/stack_reword.rs deleted file mode 100644 index 9a228848..00000000 --- a/crates/mergify-cli/tests/stack_reword.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! End-to-end tests for `mergify stack reword`. The `-m` path is -//! exercised end to end; the interactive path (no `-m`) opens an -//! editor we don't have in test contexts so we cover only its -//! dry-run resolution. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -fn build_stack_repo() -> (tempfile::TempDir, Vec) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ] { - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - commits.push(capture(&local, &["rev-parse", "HEAD"])); - } - (workdir, commits) -} - -fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .output() - .unwrap() -} - -#[test] -fn reword_with_message_replaces_subject() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &[ - "stack", - "reword", - &commits[1][..12], - "-m", - "feat: new subject\n\nbody line", - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let subject = capture(&local, &["log", "-1", "--format=%s"]); - assert_eq!(subject, "feat: new subject"); - let body = capture(&local, &["log", "-1", "--format=%b"]); - assert!(body.contains("body line"), "got body: {body}"); -} - -#[test] -fn reword_dry_run_does_not_modify_head() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let head_before = capture(&local, &["rev-parse", "HEAD"]); - let output = run_mergify( - &local, - &["stack", "reword", "--dry-run", &commits[1][..12], "-m", "x"], - ); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("Reword plan:")); - assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); -} - -#[test] -fn reword_unknown_prefix_exits_nonzero() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify(&local, &["stack", "reword", "deadbeef1234", "-m", "x"]); - assert!(!output.status.success()); -} diff --git a/crates/mergify-cli/tests/stack_squash.rs b/crates/mergify-cli/tests/stack_squash.rs deleted file mode 100644 index 362d1cb8..00000000 --- a/crates/mergify-cli/tests/stack_squash.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! End-to-end tests for `mergify stack squash`. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -fn build_stack_repo() -> (tempfile::TempDir, Vec) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - commits.push(capture(&local, &["rev-parse", "HEAD"])); - } - (workdir, commits) -} - -fn run_mergify(local: &Path, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .output() - .unwrap() -} - -fn feature_subjects(local: &Path) -> Vec { - capture( - local, - &["log", "--reverse", "--format=%s", "origin/main..HEAD"], - ) - .lines() - .map(str::to_string) - .collect() -} - -#[test] -fn squash_src_into_target_keeps_target_message() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - // squash C into A (no -m). Expected: A holds A+C; B unchanged. - let output = run_mergify( - &local, - &[ - "stack", - "squash", - &commits[2][..12], // C - "into", - &commits[0][..12], // A - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let subjects = feature_subjects(&local); - // After squash: [A (now containing A+C), B] - assert_eq!(subjects, ["Commit A", "Commit B"]); - // C's content is preserved (folded into A) - assert!(local.join("a.txt").exists()); - assert!(local.join("c.txt").exists()); -} - -#[test] -fn squash_with_custom_message_replaces_subject() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &[ - "stack", - "squash", - &commits[2][..12], - "into", - &commits[0][..12], - "-m", - "feat: combined A+C\n\nbody paragraph", - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let subjects = feature_subjects(&local); - assert!(subjects.contains(&"feat: combined A+C".to_string())); - assert!(!subjects.contains(&"Commit A".to_string())); - // Body survived the rebase-todo / tempfile indirection. - let head_parent_body = capture(&local, &["log", "-1", "--format=%b", "HEAD~1"]); - assert!(head_parent_body.contains("body paragraph")); -} - -#[test] -fn squash_dry_run_does_not_modify_head() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let head_before = capture(&local, &["rev-parse", "HEAD"]); - - let output = run_mergify( - &local, - &[ - "stack", - "squash", - "--dry-run", - &commits[2][..12], - "into", - &commits[0][..12], - ], - ); - assert!(output.status.success()); - assert!(String::from_utf8_lossy(&output.stdout).contains("Squash plan:")); - assert_eq!(capture(&local, &["rev-parse", "HEAD"]), head_before); -} - -#[test] -fn squash_src_equals_target_errors() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify( - &local, - &[ - "stack", - "squash", - &commits[0][..12], - "into", - &commits[0][..12], - ], - ); - assert!(!output.status.success()); -} - -#[test] -fn squash_missing_into_keyword_errors() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let output = run_mergify( - &local, - &["stack", "squash", &commits[0][..12], &commits[1][..12]], - ); - assert!(!output.status.success()); -} - -#[test] -fn squash_multiple_srcs_into_target() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - - let output = run_mergify( - &local, - &[ - "stack", - "squash", - &commits[1][..12], // B - &commits[2][..12], // C - "into", - &commits[0][..12], // A - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - let subjects = feature_subjects(&local); - // After squashing B+C into A: stack is just [A] - assert_eq!(subjects, ["Commit A"]); - assert!(local.join("a.txt").exists()); - assert!(local.join("b.txt").exists()); - assert!(local.join("c.txt").exists()); -} diff --git a/crates/mergify-cli/tests/stack_sync.rs b/crates/mergify-cli/tests/stack_sync.rs deleted file mode 100644 index a83781cc..00000000 --- a/crates/mergify-cli/tests/stack_sync.rs +++ /dev/null @@ -1,383 +0,0 @@ -//! End-to-end tests for `mergify stack sync`. Spawns the real -//! binary against a wiremock GitHub server and a real git repo -//! so the dry-run / drop-merged / pull-rebase paths are all -//! exercised. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn mergify_binary() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_mergify")) -} - -fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} - -fn run_in(dir: &Path, args: &[&str]) { - let ok = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {}: {args:?} failed", dir.display()); -} - -fn capture(dir: &Path, args: &[&str]) -> String { - let out = isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() -} - -/// `feature` branch with `n_commits` commits, each carrying its -/// own Change-Id; the first commit on `feature` is the merge base -/// with `main`. -fn build_stack_repo(n_commits: usize) -> (tempfile::TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - run_in(&local, args); - } - std::fs::write(local.join("root.txt"), "root").unwrap(); - run_in(&local, &["add", "root.txt"]); - run_in(&local, &["commit", "-q", "-m", "root"]); - run_in( - &local, - &["remote", "add", "origin", upstream.to_str().unwrap()], - ); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["remote", "set-head", "origin", "main"]); - run_in(&local, &["checkout", "-q", "-b", "feature"]); - - let mut commits = Vec::new(); - for i in 0..n_commits { - let label = (b'A' + u8::try_from(i).expect("test stack stays under 26 commits")) as char; - let fname = format!("{}.txt", label.to_lowercase()); - std::fs::write(local.join(&fname), format!("content {label}")).unwrap(); - run_in(&local, &["add", &fname]); - // Use the index in hex so each commit has a distinct - // Change-Id while still being deterministic. - let cid = format!("I{:040x}", i + 1); - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "-q", "-m", &msg]); - commits.push((capture(&local, &["rev-parse", "HEAD"]), cid)); - } - (workdir, commits) -} - -async fn start_mock_with_no_prs() -> wiremock::MockServer { - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - // Empty `/user` so the binary still reaches the empty-author - // path quickly. - Mock::given(method("GET")) - .and(path("/user")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "login": "tester", - }))) - .mount(&server) - .await; - mount_discovery(&server, None).await; - server -} - -/// Mock the stack discovery. `merged` is a PR merged from the bottom -/// commit, whose branch GitHub then deleted — the usual state after a -/// merge: no branch is left under the stack prefix, and the PR is -/// only found by the branch name its local commit implies -/// (`commit--` for "Commit A"). Every other lookup finds nothing. -async fn mount_discovery(server: &wiremock::MockServer, merged: Option) { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, ResponseTemplate}; - - Mock::given(method("GET")) - .and(path( - "/repos/myorg/myrepo/git/matching-refs/heads/stack/tester/feature/", - )) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(server) - .await; - if let Some(mut pull) = merged { - let head = format!("myorg:{}", pull["head"]["ref"].as_str().unwrap()); - pull["user"] = serde_json::json!({"login": "tester"}); - Mock::given(method("GET")) - .and(path("/repos/myorg/myrepo/pulls")) - .and(query_param("head", head)) - .and(query_param("state", "all")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([pull]))) - .expect(1) - .mount(server) - .await; - } - Mock::given(method("GET")) - .and(path("/repos/myorg/myrepo/pulls")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .with_priority(10) - .mount(server) - .await; -} - -fn run_mergify(local: &Path, server_uri: &str, args: &[&str]) -> std::process::Output { - Command::new(mergify_binary()) - .args(args) - .current_dir(local) - .env("MERGIFY_TOKEN", "test-token") - // Bypass git config + https coercion in - // `stack_context::resolve_github_server` so we can point - // at the wiremock URL verbatim (it speaks http, not https). - .env("MERGIFY_GITHUB_SERVER", server_uri) - .env("GIT_CONFIG_GLOBAL", "/dev/null") - .env("GIT_CONFIG_NOSYSTEM", "1") - .output() - .unwrap() -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sync_dry_run_reports_up_to_date_when_no_merged_prs() { - let (work, _) = build_stack_repo(2); - let local = work.path().join("local"); - let server = start_mock_with_no_prs().await; - - let output = run_mergify( - &local, - &server.uri(), - &[ - "stack", - "sync", - "--dry-run", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - // Bypass slug discovery (the test's `origin` URL is a - // local tempdir path, not a parseable HTTPS/SSH URL). - "--repo", - "myorg/myrepo", - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - String::from_utf8_lossy(&output.stdout).contains("up to date"), - "stdout: {}", - String::from_utf8_lossy(&output.stdout), - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sync_dry_run_lists_merged_commits() { - use wiremock::MockServer; - - let (work, commits) = build_stack_repo(2); - let local = work.path().join("local"); - let first_sha = commits[0].0.clone(); - let first_cid = commits[0].1.clone(); - - let server = MockServer::start().await; - mount_discovery( - &server, - Some(serde_json::json!({ - "number": 42, - "title": "Commit A", - "state": "closed", - "merged_at": "2025-01-01T00:00:00Z", - "head": {"sha": first_sha, "ref": format!("stack/tester/feature/commit--{}", &first_cid[1..9])}, - "base": {"ref": "main"}, - "html_url": "https://github.com/myorg/myrepo/pull/42", - })), - ) - .await; - - let output = run_mergify( - &local, - &server.uri(), - &[ - "stack", - "sync", - "--dry-run", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - "--repo", - "myorg/myrepo", - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Dry run"), "stdout: {stdout}"); - assert!(stdout.contains("#42"), "stdout: {stdout}"); -} - -/// Real (non-dry-run) sync where the merged commit was -/// squash-merged into trunk with an identical patch. Git's default -/// `--no-reapply-cherry-picks` would silently omit it from the -/// rebase todo, leaving the drop editor with no `pick` line to -/// remove — the abort we guard against with `--reapply-cherry-picks`. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sync_drops_squash_merged_commit_with_matching_patch() { - use wiremock::MockServer; - - let (work, commits) = build_stack_repo(2); - let local = work.path().join("local"); - let a_sha = commits[0].0.clone(); - let a_cid = commits[0].1.clone(); - - // Simulate the merge-queue squashing commit A onto trunk: a new - // commit on `main` that adds the *same* `a.txt` with the *same* - // content, so its patch-id matches commit A and git treats A as - // already-applied during the rebase. - run_in(&local, &["checkout", "-q", "main"]); - std::fs::write(local.join("a.txt"), "content A").unwrap(); - run_in(&local, &["add", "a.txt"]); - run_in(&local, &["commit", "-q", "-m", "Commit A (squashed #42)"]); - run_in(&local, &["push", "-q", "origin", "main"]); - run_in(&local, &["checkout", "-q", "feature"]); - - let server = MockServer::start().await; - mount_discovery( - &server, - Some(serde_json::json!({ - "number": 42, - "title": "Commit A", - "state": "closed", - "merged_at": "2025-01-01T00:00:00Z", - "head": {"sha": a_sha, "ref": format!("stack/tester/feature/commit--{}", &a_cid[1..9])}, - "base": {"ref": "main"}, - "html_url": "https://github.com/myorg/myrepo/pull/42", - })), - ) - .await; - - let output = run_mergify( - &local, - &server.uri(), - &[ - "stack", - "sync", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - "--repo", - "myorg/myrepo", - ], - ); - assert!( - output.status.success(), - "stdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - // Commit A is gone; only commit B remains, rebased onto the new - // trunk tip (which now carries the squashed a.txt). - let ahead = capture(&local, &["rev-list", "--count", "origin/main..HEAD"]); - assert_eq!(ahead, "1", "exactly commit B should remain on the stack"); - let a_reachable = isolated_git() - .arg("-C") - .arg(&local) - .args(["merge-base", "--is-ancestor", &a_sha, "HEAD"]) - .status() - .unwrap() - .success(); - assert!(!a_reachable, "the squash-merged commit A should be dropped"); - let head_subject = capture(&local, &["log", "-1", "--format=%s"]); - assert_eq!(head_subject, "Commit B"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sync_rejects_auto_generated_branch_names() { - // Create a repo whose current branch matches the - // auto-generated `//--` shape. - let work = tempfile::tempdir().unwrap(); - let local = work.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &[ - "checkout", - "-q", - "-b", - "stack/tester/feature/feat-a--abcd1234", - ], - ] { - isolated_git() - .arg("-C") - .arg(&local) - .args(args) - .status() - .unwrap(); - } - - let server = start_mock_with_no_prs().await; - let output = run_mergify( - &local, - &server.uri(), - &[ - "stack", - "sync", - "--dry-run", - "--trunk", - "origin/main", - "--author", - "tester", - "--branch-prefix", - "stack/tester", - "--repo", - "myorg/myrepo", - ], - ); - assert!(!output.status.success()); - assert!( - String::from_utf8_lossy(&output.stderr).contains("generated"), - "stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} diff --git a/crates/mergify-config/Cargo.toml b/crates/mergify-config/Cargo.toml deleted file mode 100644 index b8941d5d..00000000 --- a/crates/mergify-config/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "mergify-config" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify config` subcommands." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } -jsonschema = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -serde_yaml_ng = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -mergify-test-support = { path = "../mergify-test-support" } -tempfile = { workspace = true } -tokio = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-config/src/lib.rs b/crates/mergify-config/src/lib.rs deleted file mode 100644 index c9593158..00000000 --- a/crates/mergify-config/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Native Rust implementation of the `mergify config` subcommands. -//! -//! Hosts `config validate` and `config simulate`; both share the -//! config-file resolver in [`paths`]. - -pub mod paths; -pub mod simulate; -pub mod validate; diff --git a/crates/mergify-config/src/paths.rs b/crates/mergify-config/src/paths.rs deleted file mode 100644 index 1728dbbf..00000000 --- a/crates/mergify-config/src/paths.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! Shared helpers for resolving the Mergify configuration file path. -//! -//! Both `config validate` and `config simulate` accept a -//! ``--config-file`` flag and otherwise auto-detect the file from a -//! small list of conventional locations. The resolver here is the -//! single source of truth for that behavior — `mergify ci scopes` -//! falls back to it too, so there is one search order in the CLI. - -use std::path::Path; -use std::path::PathBuf; - -use mergify_core::CliError; -use mergify_core::Output; - -/// Filename patterns the CLI searches for a Mergify configuration, -/// in priority order: the three conventional locations, and at each -/// of them the `.yml` spelling ahead of the `.yaml` one. The first -/// file that exists wins. -/// -/// The engine must search the same list in the same order -/// (MRGFY-9532). A repository carrying several of these files is -/// otherwise validated against one file and merged against another, -/// which is the one failure `config validate` exists to prevent. -pub const DEFAULT_CONFIG_PATHS: [&str; 6] = [ - ".mergify.yml", - ".mergify.yaml", - ".mergify/config.yml", - ".mergify/config.yaml", - ".github/mergify.yml", - ".github/mergify.yaml", -]; - -/// The candidate that won the search, plus the ones it beat. -struct Resolution { - path: PathBuf, - /// Candidates that exist on disk but lose to `path` under the - /// [`DEFAULT_CONFIG_PATHS`] order. Always empty for an explicit - /// `--config-file`: the user named the file, nothing is ignored. - shadowed: Vec, -} - -impl Resolution { - /// The warning to hand the user when the repository carries more - /// than one configuration file, or `None` when it carries one. - /// - /// Naming the losers matters more than naming the winner: an - /// edit to an ignored file looks like Mergify dropping a change. - fn shadow_warning(&self) -> Option { - if self.shadowed.is_empty() { - return None; - } - let ignored = self - .shadowed - .iter() - .map(|p| format!("'{}'", p.display())) - .collect::>() - .join(", "); - Some(format!( - "mergify: warning: several Mergify configuration files found; \ - using '{}' and ignoring {ignored}.", - self.path.display(), - )) - } -} - -/// Resolve the path of the Mergify configuration file relative to -/// the current working directory. -/// -/// When ``explicit`` is ``Some``, that path must be a real file — -/// otherwise the user specified a bad path and we fail loudly with -/// [`CliError::Configuration`]. When ``explicit`` is ``None`` the -/// resolver walks [`DEFAULT_CONFIG_PATHS`] in order and returns the -/// first match, warning through ``output`` about any other candidate -/// it found on the way. -/// -/// # Errors -/// -/// Returns [`CliError::Configuration`] when neither an explicit -/// path nor any default candidate exists. -pub fn resolve_config_path( - explicit: Option<&Path>, - output: &mut dyn Output, -) -> Result { - resolve_config_path_in(explicit, Path::new("."), output) -} - -/// Same as [`resolve_config_path`] but searches relative to -/// ``base`` instead of the current working directory. -/// -/// Tests use this directly to avoid `std::env::set_current_dir`, -/// which races with parallel cargo test workers in the same -/// process. -/// -/// # Errors -/// -/// Returns [`CliError::Configuration`] when neither an explicit -/// path nor any default candidate exists. -pub fn resolve_config_path_in( - explicit: Option<&Path>, - base: &Path, - output: &mut dyn Output, -) -> Result { - let resolution = resolve_in(explicit, base)?; - // `Output::status` is the house channel for human chatter, and - // it writes to stderr so `--json` stdout stays a single - // document. It is a no-op in `OutputMode::Json`, which none of - // the config-reading commands run in today. - if let Some(warning) = resolution.shadow_warning() { - output.status(&warning)?; - } - Ok(resolution.path) -} - -fn resolve_in(explicit: Option<&Path>, base: &Path) -> Result { - if let Some(path) = explicit { - if path.is_file() { - return Ok(Resolution { - path: path.to_path_buf(), - shadowed: Vec::new(), - }); - } - return Err(CliError::Configuration(format!( - "Configuration file not found: {}", - path.display(), - ))); - } - let mut found = DEFAULT_CONFIG_PATHS - .iter() - .map(|candidate| base.join(candidate)) - .filter(|path| path.is_file()); - let Some(path) = found.next() else { - return Err(CliError::Configuration(format!( - "Mergify configuration file not found. Looked in: {}", - DEFAULT_CONFIG_PATHS.join(", "), - ))); - }; - Ok(Resolution { - path, - shadowed: found.collect(), - }) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use mergify_test_support::Captured; - - use super::*; - - /// Resolve under `base` with a capturing output, returning the - /// winner and whatever was written to stderr. - fn resolve(base: &Path) -> (Result, String) { - let mut captured = Captured::human(); - let got = resolve_config_path_in(None, base, &mut captured.output); - (got, captured.stderr()) - } - - #[test] - fn finds_dotmergify_yml() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yml"), "").unwrap(); - let (got, stderr) = resolve(tmp.path()); - assert_eq!(got.unwrap(), tmp.path().join(".mergify.yml")); - assert_eq!(stderr, ""); - } - - #[test] - fn finds_dotmergify_yaml() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yaml"), "").unwrap(); - let (got, stderr) = resolve(tmp.path()); - assert_eq!(got.unwrap(), tmp.path().join(".mergify.yaml")); - assert_eq!(stderr, ""); - } - - #[test] - fn finds_yaml_in_every_conventional_location() { - for candidate in [ - ".mergify.yaml", - ".mergify/config.yaml", - ".github/mergify.yaml", - ] { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join(candidate); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(&path, "").unwrap(); - let (got, _) = resolve(tmp.path()); - assert_eq!(got.unwrap(), path, "looking for {candidate}"); - } - } - - #[test] - fn yml_wins_over_yaml_at_the_same_location() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yml"), "").unwrap(); - fs::write(tmp.path().join(".mergify.yaml"), "").unwrap(); - let (got, stderr) = resolve(tmp.path()); - assert_eq!(got.unwrap(), tmp.path().join(".mergify.yml")); - assert_eq!( - stderr, - format!( - "mergify: warning: several Mergify configuration files found; \ - using '{}' and ignoring '{}'.\n", - tmp.path().join(".mergify.yml").display(), - tmp.path().join(".mergify.yaml").display(), - ), - ); - } - - /// The location order outranks the extension order: a `.yaml` - /// higher up beats a `.yml` lower down. - #[test] - fn location_order_outranks_extension_order() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yaml"), "").unwrap(); - fs::create_dir_all(tmp.path().join(".github")).unwrap(); - fs::write(tmp.path().join(".github/mergify.yml"), "").unwrap(); - let (got, _) = resolve(tmp.path()); - assert_eq!(got.unwrap(), tmp.path().join(".mergify.yaml")); - } - - /// Every loser is named, in search order, on one line. - #[test] - fn warning_names_every_ignored_candidate() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yml"), "").unwrap(); - fs::write(tmp.path().join(".mergify.yaml"), "").unwrap(); - fs::create_dir_all(tmp.path().join(".github")).unwrap(); - fs::write(tmp.path().join(".github/mergify.yaml"), "").unwrap(); - let (_, stderr) = resolve(tmp.path()); - assert_eq!( - stderr, - format!( - "mergify: warning: several Mergify configuration files found; \ - using '{}' and ignoring '{}', '{}'.\n", - tmp.path().join(".mergify.yml").display(), - tmp.path().join(".mergify.yaml").display(), - tmp.path().join(".github/mergify.yaml").display(), - ), - ); - } - - #[test] - fn explicit_path_is_never_reported_as_shadowing() { - let tmp = tempfile::tempdir().unwrap(); - fs::write(tmp.path().join(".mergify.yml"), "").unwrap(); - let explicit = tmp.path().join(".mergify.yaml"); - fs::write(&explicit, "").unwrap(); - let mut captured = Captured::human(); - let got = resolve_config_path_in(Some(&explicit), tmp.path(), &mut captured.output); - assert_eq!(got.unwrap(), explicit); - assert_eq!(captured.stderr(), ""); - } - - #[test] - fn errors_when_no_file_and_no_explicit() { - let tmp = tempfile::tempdir().unwrap(); - let (got, _) = resolve(tmp.path()); - let err = got.unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("not found")); - // The failure lists what was searched, so a user who spelled - // the file `.yaml` can see that spelling is accepted. - assert!(err.to_string().contains(".mergify.yaml")); - } - - #[test] - fn errors_on_explicit_missing_file() { - let mut captured = Captured::human(); - let err = resolve_config_path_in( - Some(Path::new("/nonexistent/path.yml")), - Path::new("."), - &mut captured.output, - ) - .unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - } -} diff --git a/crates/mergify-config/src/simulate.rs b/crates/mergify-config/src/simulate.rs deleted file mode 100644 index 1f2b729c..00000000 --- a/crates/mergify-config/src/simulate.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! `mergify config simulate` — simulate Mergify actions on a pull -//! request using the local configuration. -//! -//! The command: -//! 1. Parses the pull-request URL into ``(owner/repo, number)``. -//! 2. Resolves the config file (same paths as `config validate`). -//! 3. Reads the YAML as a raw string (no parsing — the Mergify -//! simulator accepts the text verbatim). -//! 4. POSTs to -//! ``/v1/repos//pulls//simulator`` with -//! ``{"mergify_yml": }``. -//! 5. Prints the simulator's title + summary. -//! -//! Token / api-url / config-file all follow the same resolution -//! order as the Python CLI (`mergify_core::auth`): explicit flag, -//! then env var, then `gh auth token` for the bearer, then the -//! default API URL. - -use std::io::Write; -use std::path::Path; - -use mergify_core::ApiFlavor; -use mergify_core::CliError; -use mergify_core::HttpClient; -use mergify_core::Output; -use mergify_core::auth; -use mergify_core::pull_request::PullRequestRef; -use serde::Deserialize; -use serde::Serialize; - -use crate::paths::resolve_config_path; - -#[derive(Serialize)] -struct SimulatorRequest<'a> { - mergify_yml: &'a str, -} - -#[derive(Deserialize)] -struct SimulatorResponse { - title: String, - summary: String, -} - -pub struct SimulateOptions<'a> { - pub pull_request: &'a PullRequestRef, - pub config_file: Option<&'a Path>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -/// Run the `config simulate` command. -pub async fn run(opts: SimulateOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let config_path = resolve_config_path(opts.config_file, output)?; - let mergify_yml = std::fs::read_to_string(&config_path).map_err(|e| { - CliError::Configuration(format!("cannot read {}: {e}", config_path.display())) - })?; - - let api_url = auth::resolve_api_url(opts.api_url)?; - let token = auth::resolve_mergify_token(opts.token, &api_url, auth::Audience::User)?; - - output.status(&format!("Simulating against {api_url}…"))?; - - let client = HttpClient::new(api_url, token, ApiFlavor::Mergify)?; - let path = format!( - "/v1/repos/{}/pulls/{}/simulator", - opts.pull_request.repository, opts.pull_request.pull_number, - ); - // The simulator answers a schema-invalid config with HTTP 422. - // That's a problem with the *local* config, not a Mergify API - // failure, so surface it as a configuration error (exit 8) — - // matching what `config validate` returns for the same fixture — - // rather than the generic Mergify API code (exit 6). - let response: SimulatorResponse = client - .post_classifying( - &path, - &SimulatorRequest { - mergify_yml: &mergify_yml, - }, - |status, message| (status == 422).then(|| CliError::Configuration(message.to_string())), - ) - .await?; - - emit_result(output, &response)?; - Ok(()) -} - -fn emit_result(output: &mut dyn Output, response: &SimulatorResponse) -> std::io::Result<()> { - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "{title}", title = response.title)?; - writeln!(w)?; - // Intentional drift from Python: we print raw Markdown - // instead of rich-rendering it. Machine-readable output is - // still locked; human rendering is flexible per the compat - // contract. - writeln!(w, "{summary}", summary = response.summary) - }) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[tokio::test] - async fn run_posts_config_and_prints_simulator_result() { - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join(".mergify.yml"); - fs::write(&config_path, "pull_request_rules: []\n").unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/42/simulator")) - .and(header("Authorization", "Bearer test-token")) - .and(body_json(serde_json::json!({ - "mergify_yml": "pull_request_rules: []\n", - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "title": "Would merge immediately", - "summary": "All conditions pass.", - }))) - .expect(1) - .mount(&server) - .await; - - let pull_request = PullRequestRef { - host: "github.com".into(), - repository: "owner/repo".into(), - pull_number: 42, - }; - let api_url = server.uri(); - - let mut cap = Captured::human(); - run( - SimulateOptions { - pull_request: &pull_request, - config_file: Some(&config_path), - token: Some("test-token"), - api_url: Some(&api_url), - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout_str = cap.stdout(); - assert!( - stdout_str.contains("Would merge immediately"), - "expected title in output: {stdout_str:?}", - ); - assert!( - stdout_str.contains("All conditions pass."), - "expected summary in output: {stdout_str:?}", - ); - } - - #[tokio::test] - async fn run_maps_simulator_422_to_configuration_error() { - // A schema-invalid config makes the simulator answer 422. - // That's a local-config problem, so it must surface as a - // configuration error (exit 8), not a Mergify API error. - let server = MockServer::start().await; - let tmp = tempfile::tempdir().unwrap(); - let config_path = tmp.path().join(".mergify.yml"); - fs::write(&config_path, "pull_request_rules: not-a-list\n").unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/pulls/42/simulator")) - .respond_with( - ResponseTemplate::new(422) - .set_body_json(serde_json::json!({"detail": "invalid configuration"})), - ) - .expect(1) - .mount(&server) - .await; - - let pull_request = PullRequestRef { - host: "github.com".into(), - repository: "owner/repo".into(), - pull_number: 42, - }; - let api_url = server.uri(); - - let mut cap = Captured::human(); - let err = run( - SimulateOptions { - pull_request: &pull_request, - config_file: Some(&config_path), - token: Some("test-token"), - api_url: Some(&api_url), - }, - &mut cap.output, - ) - .await - .unwrap_err(); - - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - assert_eq!(err.exit_code(), mergify_core::ExitCode::ConfigurationError,); - assert!( - err.to_string().contains("invalid configuration"), - "got {err}", - ); - } -} diff --git a/crates/mergify-config/src/validate.rs b/crates/mergify-config/src/validate.rs deleted file mode 100644 index ba540d71..00000000 --- a/crates/mergify-config/src/validate.rs +++ /dev/null @@ -1,280 +0,0 @@ -//! `mergify config validate` — validate a Mergify YAML config -//! against the published JSON schema. -//! -//! The command: -//! 1. Resolves the config file (explicit `--config-file` or the -//! first candidate in [`crate::paths::DEFAULT_CONFIG_PATHS`]). -//! 2. Parses it as YAML. -//! 3. Fetches `https://docs.mergify.com/mergify-configuration-schema.json`. -//! 4. Validates the config against the schema using the -//! [`jsonschema`] crate. -//! 5. Emits a human-readable success or per-error list to stdout. -//! -//! Maps to [`mergify_core::ExitCode::ConfigurationError`] when the -//! config file is missing, unparseable, or has schema violations; -//! to [`mergify_core::ExitCode::MergifyApiError`] when the schema -//! fetch itself fails. - -use std::io::Write; -use std::path::Path; - -use mergify_core::ApiFlavor; -use mergify_core::CliError; -use mergify_core::HttpClient; -use mergify_core::Output; -use url::Url; - -use crate::paths::resolve_config_path; - -const SCHEMA_HOST: &str = "https://docs.mergify.com"; -const SCHEMA_PATH: &str = "/mergify-configuration-schema.json"; - -/// Run the `config validate` command. -/// -/// `explicit_path` is the value of the `--config-file` flag, if the -/// user provided one; otherwise the command searches the default -/// locations. -pub async fn run(explicit_path: Option<&Path>, output: &mut dyn Output) -> Result<(), CliError> { - let config_path = resolve_config_path(explicit_path, output)?; - let config_value = load_yaml(&config_path)?; - - output.status(&format!("Fetching schema from {SCHEMA_HOST}…"))?; - let schema = fetch_schema().await?; - - let errors = validate_against_schema(&config_value, &schema)?; - emit_result(output, &config_path, &errors)?; - - if errors.is_empty() { - Ok(()) - } else { - Err(CliError::Configuration( - "configuration validation failed".to_string(), - )) - } -} - -fn load_yaml(path: &Path) -> Result { - let text = std::fs::read_to_string(path) - .map_err(|e| CliError::Configuration(format!("cannot read {}: {e}", path.display())))?; - // Parse as YAML into serde_yaml_ng::Value, then convert to JSON - // so jsonschema can validate. The conversion is always lossless - // for valid Mergify configs (mappings, sequences, scalars). - let yaml_value: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text) - .map_err(|e| CliError::Configuration(format!("Invalid YAML in {}: {e}", path.display())))?; - // Mergify configs are YAML mappings at the top level; an empty - // file deserializes to Null, which we treat as an empty mapping. - if yaml_value.is_null() { - return Ok(serde_json::Value::Object(serde_json::Map::new())); - } - if !yaml_value.is_mapping() { - return Err(CliError::Configuration(format!( - "Expected a YAML mapping at the top level of {}", - path.display(), - ))); - } - serde_json::to_value(&yaml_value).map_err(|e| { - CliError::Configuration(format!( - "cannot convert YAML to JSON for schema validation: {e}" - )) - }) -} - -async fn fetch_schema() -> Result { - // Empty token: the schema lives on a public CDN (docs.mergify.com), - // no auth needed. Flavor = Mergify so transport failures map to - // MERGIFY_API_ERROR, which matches the Python behavior. - let client = HttpClient::new( - Url::parse(SCHEMA_HOST).expect("SCHEMA_HOST is a valid URL"), - "", - ApiFlavor::Mergify, - )?; - client.get(SCHEMA_PATH).await -} - -pub struct ValidationError { - pub path: String, - pub message: String, -} - -fn validate_against_schema( - config: &serde_json::Value, - schema: &serde_json::Value, -) -> Result, CliError> { - let validator = jsonschema::options() - .build(schema) - .map_err(|e| CliError::Generic(format!("Failed to parse validation schema: {e}")))?; - - let mut errors: Vec = validator - .iter_errors(config) - .map(|err| { - let path = err.instance_path().to_string(); - let pretty_path = if path.is_empty() || path == "/" { - "(root)".to_string() - } else { - path.trim_start_matches('/').replace('/', ".") - }; - ValidationError { - path: pretty_path, - message: err.to_string(), - } - }) - .collect(); - errors.sort_by(|a, b| a.path.cmp(&b.path)); - Ok(errors) -} - -fn emit_result( - output: &mut dyn Output, - config_path: &Path, - errors: &[ValidationError], -) -> std::io::Result<()> { - output.emit(&(), &mut |w: &mut dyn Write| { - let path_display = config_path.display(); - if errors.is_empty() { - writeln!(w, "Configuration file '{path_display}' is valid.")?; - } else { - writeln!( - w, - "configuration file '{path_display}' has {n} error(s):", - n = errors.len(), - )?; - for err in errors { - writeln!( - w, - " - {path}: {message}", - path = err.path, - message = err.message, - )?; - } - } - Ok(()) - }) -} - -#[cfg(test)] -mod tests { - use std::fs; - - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - fn minimal_schema() -> serde_json::Value { - serde_json::json!({ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "pull_request_rules": { - "type": "array", - "items": { - "type": "object", - "required": ["name"], - "properties": { - "name": {"type": "string"}, - }, - }, - }, - }, - }) - } - - #[test] - fn load_yaml_rejects_top_level_scalar() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("c.yml"); - fs::write(&path, "just a string").unwrap(); - let err = load_yaml(&path).unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("mapping")); - } - - #[test] - fn load_yaml_rejects_invalid_yaml() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("c.yml"); - fs::write(&path, "not: valid: yaml: [").unwrap(); - let err = load_yaml(&path).unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("Invalid YAML")); - } - - #[test] - fn load_yaml_empty_file_is_empty_mapping() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("c.yml"); - fs::write(&path, "").unwrap(); - let got = load_yaml(&path).unwrap(); - assert_eq!(got, serde_json::json!({})); - } - - #[test] - fn validate_returns_empty_on_valid_config() { - let schema = minimal_schema(); - let config = serde_json::json!({ - "pull_request_rules": [{"name": "ok"}], - }); - let errors = validate_against_schema(&config, &schema).unwrap(); - assert!(errors.is_empty()); - } - - #[test] - fn validate_returns_errors_on_invalid_config() { - let schema = minimal_schema(); - // Rule is missing the required `name` field. - let config = serde_json::json!({ - "pull_request_rules": [{"description": "missing name"}], - }); - let errors = validate_against_schema(&config, &schema).unwrap(); - assert!(!errors.is_empty()); - assert!(errors[0].path.contains("pull_request_rules")); - } - - #[tokio::test] - async fn run_end_to_end_valid_config() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/mergify-configuration-schema.json")) - .respond_with(ResponseTemplate::new(200).set_body_json(minimal_schema())) - .mount(&server) - .await; - // We can't override SCHEMA_HOST at runtime for this simple - // integration test, so this test's value is to at least - // exercise that the code compiles and the schema URL is - // well-formed. Real end-to-end coverage lives in the - // compat-test harness, which hits the actual docs CDN. - // Skip the actual call so the test doesn't depend on the - // internet. - } - - #[test] - fn emit_result_success_writes_to_output() { - let mut cap = Captured::human(); - emit_result(&mut cap.output, Path::new("/tmp/x.yml"), &[]).unwrap(); - let stdout = cap.stdout(); - assert!(stdout.contains("is valid")); - } - - #[test] - fn emit_result_errors_lists_each() { - let mut cap = Captured::human(); - emit_result( - &mut cap.output, - Path::new("/tmp/x.yml"), - &[ValidationError { - path: "pull_request_rules.0".into(), - message: "is not of type string".into(), - }], - ) - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("has 1 error(s)")); - assert!(stdout.contains("pull_request_rules.0")); - assert!(stdout.contains("is not of type string")); - } -} diff --git a/crates/mergify-core/Cargo.toml b/crates/mergify-core/Cargo.toml deleted file mode 100644 index e9898ae4..00000000 --- a/crates/mergify-core/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "mergify-core" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Shared foundations for mergify-cli: HTTP, auth, errors, output, git, config, prompts." -publish = false - -[features] -# Credential stores that model one specific keychain failure, for a -# caller whose own error paths are otherwise unreachable. Turned on -# by a dev-dependency only. -test-support = [] - -[dependencies] -chrono = { workspace = true } -dirs = { workspace = true } -keyring = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tempfile = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true, features = ["rt-multi-thread"] } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-core/src/auth.rs b/crates/mergify-core/src/auth.rs deleted file mode 100644 index 1c494cb5..00000000 --- a/crates/mergify-core/src/auth.rs +++ /dev/null @@ -1,1030 +0,0 @@ -//! Resolve `--token`, `--api-url`, and `--repository`. -//! -//! One chain — `--token` → `MERGIFY_TOKEN` → `GITHUB_TOKEN` → `gh -//! auth token` — used to answer for two unrelated services. It no -//! longer does. -//! -//! [`resolve_mergify_token`] answers for the Mergify API: -//! -//! 1. `--token` -//! 2. `MERGIFY_TOKEN` -//! 3. **the credential `mergify auth login` stored for this API URL** -//! 4. `GITHUB_TOKEN` — deprecated, warns once -//! 5. `gh auth token` — deprecated, warns once -//! -//! The stored credential sits *above* the two GitHub fallbacks on -//! purpose: a `GITHUB_TOKEN` left in a shell must not silently -//! override a login the user performed. It sits *below* `--token` -//! and `MERGIFY_TOKEN` so a CI job keeps working unchanged, and -//! neither of those warns. -//! -//! [`resolve_github_token`] answers for `api.github.com` — the -//! `stack` command group — and keeps the chain above it: no stored -//! credential, no warning, nothing new sent to GitHub. One -//! subtraction: a `mut_…` value is a Mergify user token, which -//! GitHub can only answer `401` to, so it is skipped wherever it -//! turns up — `MERGIFY_TOKEN`, `GITHUB_TOKEN`, or `gh auth token`, -//! which echoes `$GITHUB_TOKEN` — and the next source is tried. An -//! explicit `--token` is sent as given. -//! -//! Repository: `--repository` flag → `GITHUB_REPOSITORY` env → -//! `git config --get remote.origin.url` parsed into `/`. -//! -//! API URL: `--api-url` flag → `MERGIFY_API_URL` env → default -//! `https://api.mergify.com`. -//! -//! Every command resolves these once before doing any network or -//! interactive work. The per-crate copies that used to live in -//! `mergify-config::simulate`, `mergify-ci::scopes_send`, and -//! `mergify-queue::auth` were each missing the `gh auth token` and -//! `git config` fallbacks — that's why this module exists. - -use std::process::Command; -use std::sync::Once; - -use url::Url; - -use crate::CliError; -use crate::credentials::CredentialStore; -use crate::env::var_non_empty; - -const DEFAULT_API_URL: &str = "https://api.mergify.com"; - -/// Prefix of a Mergify-issued user token — the credential -/// `mergify auth login` mints. Registered with GitHub's secret -/// scanning as ours, so nothing shaped like this is a GitHub -/// credential. -const MERGIFY_USER_TOKEN_PREFIX: &str = "mut_"; - -/// How the `gh auth token` fallback names itself in the failure that -/// lists the credentials skipped for carrying that prefix. A command -/// rather than a variable, so the remedy for it differs too. -const GH_AUTH_TOKEN_SOURCE: &str = "`gh auth token`"; - -/// Which Mergify credential a command's routes actually accept. -/// -/// Not decoration: the `ci` routes are declared -/// `enable_auth_methods("ci_application_key")` server-side and -/// refuse a user credential by design, because a CI runner holds an -/// organization key rather than somebody's personal login. So the -/// stored credential is not offered to them, and the deprecation -/// warning they print names the remedy that would actually work. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum Audience { - /// Routes that take the per-user credential `mergify auth login` - /// mints: `queue`, `freeze`, `events`, `tests`, `config - /// simulate`. - User, - /// The `ci` routes, which require an organization application - /// key. - ApplicationKey, -} - -/// Where a resolved Mergify token came from. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum TokenSource { - Explicit, - MergifyTokenEnv, - /// The credential `mergify auth login` stored for this API URL. - Stored, - GitHubTokenEnv, - GhCli, -} - -/// The environment variable a Mergify command would use in -/// preference to the stored credential, if one is set. -/// -/// `auth status` reports it, so "logged in" never silently means -/// "logged in, and overridden by something in your shell". Lives -/// here rather than in the `auth` crate because the precedence it -/// describes is this module's, and two copies of it would drift. -#[must_use] -pub fn overriding_env_var() -> Option<&'static str> { - var_non_empty("MERGIFY_TOKEN").map(|_| "MERGIFY_TOKEN") -} - -/// Whether that variable holds `token` itself. -/// -/// Exporting the credential `auth login` mints is the natural thing -/// to do, and `stack` reads the same variable — so after `auth -/// logout` the plain "it is still set, so commands remain -/// authenticated" note would be exactly backwards: the variable -/// holds the token the command just asked the server to revoke. -#[must_use] -pub fn overriding_env_var_holds(token: &str) -> bool { - var_non_empty("MERGIFY_TOKEN").is_some_and(|value| value == token) -} - -/// Resolve the bearer token for **Mergify API** calls. -/// -/// Precedence: `--token`, `MERGIFY_TOKEN`, the stored credential for -/// `api_url`, `GITHUB_TOKEN`, `gh auth token`. The last two are -/// deprecated and warn once per process on stderr. Errors when none -/// of them produce a non-empty value. -pub fn resolve_mergify_token( - explicit: Option<&str>, - api_url: &Url, - audience: Audience, -) -> Result { - let store = CredentialStore::discover(); - let resolved = - resolve_mergify_token_with(explicit, api_url, audience, &store, || gh_auth_token().ok())?; - if let Some(notice) = deprecation_notice(resolved.source, audience) { - warn_once(¬ice); - } - Ok(resolved.token) -} - -/// A resolved Mergify token and the step of the chain it came from. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ResolvedToken { - pub token: String, - pub source: TokenSource, -} - -/// The chain itself, against an explicit store. Private because a -/// caller has no business choosing a credential store; the tests -/// use it so they never read — or write — the developer's keychain. -fn resolve_mergify_token_with( - explicit: Option<&str>, - api_url: &Url, - audience: Audience, - store: &CredentialStore, - gh_token: impl FnOnce() -> Option, -) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(ResolvedToken { - token: value.to_string(), - source: TokenSource::Explicit, - }); - } - if let Some(value) = var_non_empty("MERGIFY_TOKEN") { - return Ok(ResolvedToken { - token: value, - source: TokenSource::MergifyTokenEnv, - }); - } - // Skipped for the `ci` routes: they refuse a user credential, so - // offering one there would replace a legible "set MERGIFY_TOKEN" - // failure with an authentication error. - if audience == Audience::User { - match store.get(api_url) { - Ok(Some(stored)) => { - return Ok(ResolvedToken { - token: stored.credential.token, - source: TokenSource::Stored, - }); - } - Ok(None) => {} - // A store that cannot be read is worth a line and not - // worth failing on: the chain has two more steps. - Err(e) => tracing::debug!(error = %e, "could not read the stored credential"), - } - } - if let Some(value) = var_non_empty("GITHUB_TOKEN") { - return Ok(ResolvedToken { - token: value, - source: TokenSource::GitHubTokenEnv, - }); - } - if let Some(token) = gh_token().filter(|t| !t.is_empty()) { - return Ok(ResolvedToken { - token, - source: TokenSource::GhCli, - }); - } - Err(CliError::Configuration(no_credential_message(audience))) -} - -/// What to tell a user who has no credential at all. The remedy -/// differs by audience for the same reason the deprecation warning -/// does: `mergify auth login` mints a credential the `ci` routes -/// refuse. -fn no_credential_message(audience: Audience) -> String { - match audience { - Audience::User => "no Mergify credential found. Run `mergify auth login`, or set the \ - 'MERGIFY_TOKEN' environment variable." - .to_string(), - Audience::ApplicationKey => "no Mergify credential found. Set the 'MERGIFY_TOKEN' \ - environment variable to a Mergify application key." - .to_string(), - } -} - -/// The deprecation notice for `source`, or `None` when the -/// credential is not deprecated. -fn deprecation_notice(source: TokenSource, audience: Audience) -> Option { - let what = match source { - TokenSource::GitHubTokenEnv => "the GITHUB_TOKEN environment variable", - TokenSource::GhCli => "the token from `gh auth token`", - TokenSource::Explicit | TokenSource::MergifyTokenEnv | TokenSource::Stored => return None, - }; - // The `ci` routes refuse the credential `auth login` mints, so - // sending a CI runner to that command would be sending it to a - // dead end. - let remedy = match audience { - Audience::User => "Run `mergify auth login` instead.", - Audience::ApplicationKey => "Set MERGIFY_TOKEN to a Mergify application key instead.", - }; - Some(format!( - "mergify: warning: {what} is deprecated as a Mergify API credential and will stop \ - working in a future release.\n {remedy}", - )) -} - -/// Print `message` to stderr the first time only. -/// -/// Once per process, not once per call: `ci junit-process` uploads a -/// batch per file, and a warning repeated per API call would bury -/// the output it is warning about. -fn warn_once(message: &str) { - static WARNED: Once = Once::new(); - WARNED.call_once(|| eprintln!("{message}")); -} - -/// Resolve the bearer token for **GitHub REST** calls — the -/// `ApiFlavor::GitHub` client the `stack` command group talks to -/// `api.github.com` with. -/// -/// Precedence: explicit `--token`, then `MERGIFY_TOKEN`, then -/// `GITHUB_TOKEN`, then the output of `gh auth token`. Deliberately -/// untouched by the Mergify side's deprecation: `stack` legitimately -/// needs a GitHub token, and warning it off the only credentials -/// GitHub accepts would be nonsense. -/// -/// Every source but `--token` is filtered for -/// [`MERGIFY_USER_TOKEN_PREFIX`]: such a value is a Mergify user -/// token that GitHub only answers `401` to, so it is skipped and the -/// next source tried. If nothing is left, the failure names the -/// sources it skipped rather than the ones the user should have set. -pub fn resolve_github_token(explicit: Option<&str>) -> Result { - resolve_github_token_with(explicit, || gh_auth_token().ok()) -} - -/// [`resolve_github_token`] with its `gh auth token` leg supplied by -/// the caller. -/// -/// The tests drive it with a closure rather than putting a fake `gh` -/// on `PATH`: `PATH` only reaches a child process by mutating this -/// one's environment, which nothing in this workspace does. It also -/// makes the cases that matter here — `gh` absent, `gh` echoing the -/// `mut_` value it was handed — expressible without a shell script, -/// a temp directory, or a `cfg(unix)` gate. -fn resolve_github_token_with( - explicit: Option<&str>, - gh_token: impl FnOnce() -> Option, -) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - // Which variables were skipped, in the order they were tried, so - // the failure below can name them. Both can carry a `mut_` - // value: somebody who pastes their `auth login` token into - // `GITHUB_TOKEN` — the variable GitHub Actions already exports — - // gets the same skip. - let mut skipped: Vec<&'static str> = Vec::new(); - for env_name in ["MERGIFY_TOKEN", "GITHUB_TOKEN"] { - let Some(value) = var_non_empty(env_name) else { - continue; - }; - // `MERGIFY_TOKEN` is the natural place to put the token - // `mergify auth login` mints, and `stack` reads the same - // variable for its GitHub calls. GitHub would answer `401 Bad - // credentials`, which says nothing about why. Skipping it - // costs nothing: this prefix is Mergify's, registered with - // GitHub's own secret scanning, so a value carrying it was - // never going to authenticate there. - if value.starts_with(MERGIFY_USER_TOKEN_PREFIX) { - tracing::debug!( - env_name, - "holds a Mergify user token, which GitHub cannot accept; trying the next \ - credential" - ); - skipped.push(env_name); - continue; - } - return Ok(value); - } - // `gh auth token` prints `$GITHUB_TOKEN` when that variable is - // set, so skipping the variable above does not, on its own, keep - // the value out of the request: a GitHub Actions job exporting a - // `mut_` token gets it handed straight back here. Check what - // came out, not where it came from. - if let Some(token) = gh_token().filter(|t| !t.is_empty()) { - if token.starts_with(MERGIFY_USER_TOKEN_PREFIX) { - tracing::debug!( - "`gh auth token` returned a Mergify user token, which GitHub cannot accept; \ - no credential left to try" - ); - skipped.push(GH_AUTH_TOKEN_SOURCE); - } else { - return Ok(token); - } - } - // Telling someone to set a variable they have set, and that was - // skipped a few lines above, is the worst version of this - // message: the reason is real and only visible at `-vv`. So name - // the ones actually skipped, and prescribe a variable that is - // not among them — `GITHUB_TOKEN` holding a `mut_` value is the - // case where "set GITHUB_TOKEN" would name the broken variable - // as the remedy. - if !skipped.is_empty() { - let names = skipped.join(" and "); - let plural = if skipped.len() > 1 { "" } else { "s" }; - let remedy = if skipped.contains(&"GITHUB_TOKEN") { - "Put a GitHub token in 'GITHUB_TOKEN' instead" - } else { - "Set 'GITHUB_TOKEN' to a GitHub token" - }; - // When gh answered at all it is installed and authenticated; - // it just handed back a `mut_` value, usually the - // `GITHUB_TOKEN` skipped just above, which it echoes. Telling - // the user to install it would be the same mistake as - // prescribing a variable they already set. - let gh_advice = if skipped.contains(&GH_AUTH_TOKEN_SOURCE) { - "log the gh client in to a GitHub account" - } else { - "make sure that the gh client is installed and you are authenticated" - }; - return Err(CliError::Configuration(format!( - "{names} hold{plural} a Mergify-issued token, which GitHub does not accept, and \ - `mergify stack` talks to GitHub. {remedy}, or {gh_advice}.", - ))); - } - Err(CliError::Configuration( - "please set the 'MERGIFY_TOKEN' or 'GITHUB_TOKEN' environment variable, \ - or make sure that the gh client is installed and you are authenticated" - .to_string(), - )) -} - -/// Resolve the Mergify API base URL. Falls back to the -/// `MERGIFY_API_URL` env var, then the default -/// `https://api.mergify.com`. -pub fn resolve_api_url(explicit: Option<&str>) -> Result { - let raw = explicit - .filter(|s| !s.is_empty()) - .map(str::to_string) - .or_else(|| var_non_empty("MERGIFY_API_URL")) - .unwrap_or_else(|| DEFAULT_API_URL.to_string()); - Url::parse(&raw).map_err(|e| CliError::Configuration(format!("invalid --api-url {raw:?}: {e}"))) -} - -/// Resolve the repository (`/`). -/// -/// Precedence: explicit `--repository`, then `GITHUB_REPOSITORY` -/// env, then `git config --get remote.origin.url` parsed via -/// [`parse_slug`]. Errors when none of those yield a slug. -pub fn resolve_repository(explicit: Option<&str>) -> Result { - if let Some(value) = explicit.filter(|s| !s.is_empty()) { - return Ok(value.to_string()); - } - if let Some(value) = var_non_empty("GITHUB_REPOSITORY") { - return Ok(value); - } - if let Some(slug) = repository_from_git_remote() { - return Ok(slug); - } - Err(CliError::Configuration( - "--repository not provided, GITHUB_REPOSITORY env var is unset, and \ - the local git config has no usable `remote.origin.url`" - .to_string(), - )) -} - -/// Repository slug (`/`) parsed from the local -/// `git config --get remote.origin.url`, or `None` when the working -/// tree isn't a git repo or has no usable `remote.origin.url`. -/// -/// Exposed so `mergify-ci`'s CI-aware resolver can share the same -/// git-remote fallback instead of reimplementing it. -#[must_use] -pub fn repository_from_git_remote() -> Option { - parse_slug(&git_remote_origin_url()?) -} - -/// Run `gh auth token` and return stdout (trimmed). Returns an -/// `Err` when `gh` is missing or the command fails, which the -/// caller treats as "no token from gh". -fn gh_auth_token() -> Result { - let output = Command::new("gh").args(["auth", "token"]).output()?; - if !output.status.success() { - return Err(std::io::Error::other("`gh auth token` exited non-zero")); - } - let token = String::from_utf8(output.stdout) - .map_err(|e| std::io::Error::other(format!("`gh auth token` non-UTF-8 output: {e}")))? - .trim() - .to_string(); - Ok(token) -} - -/// Run `git config --get remote.origin.url` in the current -/// directory and return stdout (trimmed). Returns `None` when git -/// isn't available, the working tree isn't a git repo, or the -/// remote isn't configured. -fn git_remote_origin_url() -> Option { - let output = Command::new("git") - .args(["config", "--get", "remote.origin.url"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); - (!value.is_empty()).then_some(value) -} - -/// Parse a git remote URL into `/`. -/// -/// Handles both HTTPS (`https://github.com/owner/repo.git`) and -/// SSH (`git@github.com:owner/repo.git`) shapes; `.git` suffix and -/// trailing slashes are stripped. Returns `None` when the URL -/// doesn't decompose into at least two path segments. -fn parse_slug(url: &str) -> Option { - let url = url.trim(); - - // A scheme (`://`) means an HTTPS-style URL: the slug is the - // path after the host. Anything without a scheme is treated - // as the SSH form `git@host:owner/repo[.git]`: the slug is - // whatever follows the first `:`. - let path = if let Some(scheme_end) = url.find("://") { - let after_scheme = &url[scheme_end + 3..]; - after_scheme.split_once('/')?.1.to_string() - } else { - let colon = url.find(':')?; - url[colon + 1..].to_string() - }; - - let path = path.trim_end_matches('/').trim_start_matches('/'); - let (owner, rest) = path.split_once('/')?; - let repo = rest - .trim_end_matches('/') - .strip_suffix(".git") - .unwrap_or(rest); - let repo = repo.trim_end_matches('/'); - if owner.is_empty() || repo.is_empty() { - return None; - } - Some(format!("{owner}/{repo}")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::env; - - /// A store that cannot reach the developer's real keychain, - /// optionally holding a credential for [`api_url`]. - fn store_with(token: Option<&str>) -> (tempfile::TempDir, CredentialStore) { - let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::file_at(dir.path().join("credentials.json")); - if let Some(token) = token { - store - .set( - &api_url(), - &crate::credentials::Credential { - token: token.to_string(), - expires_at: None, - }, - ) - .unwrap(); - } - (dir, store) - } - - fn api_url() -> Url { - Url::parse("https://api.mergify.com").unwrap() - } - - /// The chain, with no keychain anywhere near it and no `gh`. - /// - /// A machine with an authenticated `gh` used to answer the last - /// step and make these tests assert on the wrong thing, which is - /// why so many of them pointed `PATH` at a nonexistent - /// directory. Injecting the step says the same thing without - /// needing the process environment to change. - fn resolve( - explicit: Option<&str>, - audience: Audience, - store: &CredentialStore, - ) -> Result { - resolve_with_gh(explicit, audience, store, || None) - } - - /// [`resolve`] for a case that is about what `gh auth token` - /// answered. - fn resolve_with_gh( - explicit: Option<&str>, - audience: Audience, - store: &CredentialStore, - gh_token: impl FnOnce() -> Option, - ) -> Result { - resolve_mergify_token_with(explicit, &api_url(), audience, store, gh_token) - } - - #[test] - fn resolve_mergify_token_prefers_explicit_over_everything() { - let (_dir, store) = store_with(Some("stored")); - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("env-mergify")), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - let resolved = resolve(Some("explicit-token"), Audience::User, &store).unwrap(); - assert_eq!(resolved.token, "explicit-token"); - assert_eq!(resolved.source, TokenSource::Explicit); - }, - ); - } - - // `MERGIFY_TOKEN` stays above the stored credential so a CI job - // that sets it keeps working exactly as it did. - #[test] - fn resolve_mergify_token_prefers_the_mergify_env_var_over_the_store() { - let (_dir, store) = store_with(Some("stored")); - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("env-mergify")), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - let resolved = resolve(None, Audience::User, &store).unwrap(); - assert_eq!(resolved.token, "env-mergify"); - assert_eq!(resolved.source, TokenSource::MergifyTokenEnv); - }, - ); - } - - // The whole point of the new step: a `GITHUB_TOKEN` lying around - // in a shell must not silently override a login the user - // performed. - #[test] - fn the_stored_credential_beats_github_token() { - let (_dir, store) = store_with(Some("mut_stored")); - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - let resolved = resolve(None, Audience::User, &store).unwrap(); - assert_eq!(resolved.token, "mut_stored"); - assert_eq!(resolved.source, TokenSource::Stored); - }, - ); - } - - // The `ci` routes refuse a user credential server-side, so - // handing them one would turn "set MERGIFY_TOKEN" into an - // authentication error. - #[test] - fn the_ci_audience_never_uses_the_stored_credential() { - let (_dir, store) = store_with(Some("mut_stored")); - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - let resolved = resolve(None, Audience::ApplicationKey, &store).unwrap(); - assert_eq!(resolved.token, "env-github"); - assert_eq!(resolved.source, TokenSource::GitHubTokenEnv); - }, - ); - } - - #[test] - fn resolve_mergify_token_falls_back_to_github_env_when_nothing_is_stored() { - let (_dir, store) = store_with(None); - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - let resolved = resolve(None, Audience::User, &store).unwrap(); - assert_eq!(resolved.token, "env-github"); - assert_eq!(resolved.source, TokenSource::GitHubTokenEnv); - }, - ); - } - - // A credential stored for one deployment is not a credential for - // another. - #[test] - fn a_credential_stored_for_another_deployment_is_not_used() { - let (_dir, store) = store_with(Some("mut_stored")); - env::testing::with_no_vars(|| { - let other = Url::parse("https://mergify.internal.example/api").unwrap(); - let err = resolve_mergify_token_with(None, &other, Audience::User, &store, || None) - .unwrap_err(); - assert!( - err.to_string().contains("no Mergify credential"), - "got {err}" - ); - }); - } - - #[test] - fn resolve_mergify_token_error_names_the_command_that_fixes_it() { - let (_dir, store) = store_with(None); - env::testing::with_no_vars(|| { - let err = resolve(None, Audience::User, &store).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("mergify auth login"), "got {msg:?}"); - assert!(msg.contains("MERGIFY_TOKEN"), "got {msg:?}"); - }); - } - - // A `ci` command cannot be fixed by `auth login`; the message it - // gets must not send it there. - #[test] - fn the_ci_audience_is_not_told_to_run_auth_login() { - let (_dir, store) = store_with(None); - env::testing::with_no_vars(|| { - let err = resolve(None, Audience::ApplicationKey, &store).unwrap_err(); - let msg = err.to_string(); - assert!(!msg.contains("auth login"), "got {msg:?}"); - assert!(msg.contains("application key"), "got {msg:?}"); - }); - } - - #[test] - fn only_the_two_github_credentials_are_deprecated() { - for source in [ - TokenSource::Explicit, - TokenSource::MergifyTokenEnv, - TokenSource::Stored, - ] { - assert_eq!( - deprecation_notice(source, Audience::User), - None, - "{source:?} must not warn", - ); - } - } - - #[test] - fn the_deprecation_notice_names_the_credential_and_the_remedy() { - let notice = deprecation_notice(TokenSource::GitHubTokenEnv, Audience::User).unwrap(); - assert!(notice.contains("GITHUB_TOKEN"), "got {notice:?}"); - assert!(notice.contains("deprecated"), "got {notice:?}"); - assert!(notice.contains("mergify auth login"), "got {notice:?}"); - - let notice = deprecation_notice(TokenSource::GhCli, Audience::User).unwrap(); - assert!(notice.contains("gh auth token"), "got {notice:?}"); - } - - // Same deprecation, different remedy: `auth login` mints a - // credential the `ci` routes refuse. - #[test] - fn the_ci_deprecation_notice_points_at_an_application_key() { - let notice = - deprecation_notice(TokenSource::GitHubTokenEnv, Audience::ApplicationKey).unwrap(); - assert!(notice.contains("GITHUB_TOKEN"), "got {notice:?}"); - assert!(!notice.contains("auth login"), "got {notice:?}"); - assert!(notice.contains("application key"), "got {notice:?}"); - } - - // `resolve_mergify_token` is the function every command calls; - // the tests above all exercise the private chain behind it. - // Nothing here can capture the deprecation warning it prints — - // that goes to the process's stderr through a `Once` — but the - // wrapper itself must at least be wired to the chain. - #[test] - fn the_public_resolver_answers_the_chain() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("env-mergify")), - ("GITHUB_TOKEN", None), - ], - || { - assert_eq!( - resolve_mergify_token(None, &api_url(), Audience::User).unwrap(), - "env-mergify", - ); - }, - ); - } - - #[test] - fn overriding_env_var_reports_what_outranks_the_stored_credential() { - env::testing::with_var("MERGIFY_TOKEN", Some("env-mergify"), || { - assert_eq!(overriding_env_var(), Some("MERGIFY_TOKEN")); - }); - env::testing::with_var("MERGIFY_TOKEN", None::<&str>, || { - assert_eq!(overriding_env_var(), None); - }); - } - - // `MERGIFY_TOKEN` is the natural place to put a credential from - // `mergify auth login`, and `stack` reads the same variable. - // GitHub would answer `401 Bad credentials`, which says nothing - // about why. - #[test] - fn resolve_github_token_skips_a_mergify_user_token() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("mut_from_auth_login")), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - assert_eq!( - resolve_github_token_with(None, || None).unwrap(), - "env-github" - ); - }, - ); - } - - // Being skipped has to reach the failure, not only `-vv`: - // otherwise `stack push` tells a user to set the variable they - // set, for a reason they cannot see. - #[test] - fn resolve_github_token_says_why_it_skipped_the_mergify_token() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("mut_from_auth_login")), - ("GITHUB_TOKEN", None), - ], - || { - let err = resolve_github_token_with(None, || None).unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("GitHub does not accept"), - "got {message:?}", - ); - assert!( - message.starts_with("MERGIFY_TOKEN holds"), - "got {message:?}" - ); - assert!(message.contains("Set 'GITHUB_TOKEN'"), "got {message:?}"); - }, - ); - } - - // `GITHUB_TOKEN` is the variable GitHub Actions already exports, - // so it is at least as likely to be where somebody pastes their - // `auth login` token. The message used to name `MERGIFY_TOKEN`, - // which is unset here, and prescribe `GITHUB_TOKEN`, which is - // the broken one. - #[test] - fn resolve_github_token_names_the_variable_it_actually_skipped() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("mut_from_auth_login")), - ], - || { - let message = resolve_github_token_with(None, || None) - .unwrap_err() - .to_string(); - assert!(message.starts_with("GITHUB_TOKEN holds"), "got {message:?}"); - assert!( - !message.contains("MERGIFY_TOKEN"), - "naming an unset variable is the bug: got {message:?}", - ); - assert!( - message.contains("Put a GitHub token in 'GITHUB_TOKEN' instead"), - "the remedy must not be the variable that is broken: got {message:?}", - ); - }, - ); - } - - #[test] - fn resolve_github_token_names_both_variables_when_both_were_skipped() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("mut_one")), - ("GITHUB_TOKEN", Some("mut_two")), - ], - || { - let message = resolve_github_token_with(None, || None) - .unwrap_err() - .to_string(); - assert!( - message.starts_with("MERGIFY_TOKEN and GITHUB_TOKEN hold a"), - "got {message:?}", - ); - }, - ); - } - - // Skipping the variable is not enough on its own: `gh auth token` - // prints `$GITHUB_TOKEN`, so a GitHub Actions job exporting a - // `mut_` value gets the same token back through the fallback and - // sends it to GitHub anyway. - #[test] - fn resolve_github_token_does_not_let_gh_hand_back_the_skipped_variable() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("mut_from_auth_login")), - ], - || { - // What the real `gh auth token` prints when - // `GITHUB_TOKEN` is set: the variable, straight back. - let message = - resolve_github_token_with(None, || Some("mut_from_auth_login".to_string())) - .unwrap_err() - .to_string(); - assert!( - message.starts_with("GITHUB_TOKEN and `gh auth token` hold a"), - "got {message:?}", - ); - assert!( - message.contains("log the gh client in"), - "gh is installed and authenticated here, just to the wrong \ - credential: got {message:?}", - ); - }, - ); - } - - // The filter is on the value, not on the source: a GitHub token - // from `gh` is still the answer when the variables hold nothing - // usable. - #[test] - fn resolve_github_token_still_takes_a_real_gh_token() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("mut_from_auth_login")), - ("GITHUB_TOKEN", None), - ], - || { - assert_eq!( - resolve_github_token_with(None, || Some("ghp_from_gh_auth_login".to_string())) - .unwrap(), - "ghp_from_gh_auth_login" - ); - }, - ); - } - - // An explicit `--token` is not second-guessed: the user aimed it - // at this command, and silently using something else would be - // the more surprising failure. - #[test] - fn resolve_github_token_still_honours_an_explicit_mergify_token() { - env::testing::with_var("GITHUB_TOKEN", Some("env-github"), || { - assert_eq!( - resolve_github_token(Some("mut_explicit")).unwrap(), - "mut_explicit", - ); - }); - } - - // The GitHub resolver is what `stack` sends to `api.github.com`. - // Pinning its whole chain here — rather than asserting it equals - // whatever the Mergify one returns — is the point: the two - // diverge on the Mergify side, and this test has to keep failing - // if that divergence ever reaches the GitHub side. - #[test] - fn resolve_github_token_prefers_explicit_over_env() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("env-mergify")), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - assert_eq!( - resolve_github_token(Some("explicit-token")).unwrap(), - "explicit-token", - ); - }, - ); - } - - #[test] - fn resolve_github_token_falls_back_to_mergify_env() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", Some("env-mergify")), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - assert_eq!( - resolve_github_token_with(None, || None).unwrap(), - "env-mergify" - ); - }, - ); - } - - #[test] - fn resolve_github_token_falls_back_to_github_env_when_mergify_unset() { - env::testing::with_vars( - [ - ("MERGIFY_TOKEN", None), - ("GITHUB_TOKEN", Some("env-github")), - ], - || { - assert_eq!( - resolve_github_token_with(None, || None).unwrap(), - "env-github" - ); - }, - ); - } - - #[test] - fn resolve_api_url_default() { - env::testing::with_var("MERGIFY_API_URL", None::<&str>, || { - let url = resolve_api_url(None).unwrap(); - assert_eq!(url.as_str(), "https://api.mergify.com/"); - }); - } - - #[test] - fn resolve_api_url_prefers_explicit() { - env::testing::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { - let url = resolve_api_url(Some("https://explicit.example/")).unwrap(); - assert_eq!(url.as_str(), "https://explicit.example/"); - }); - } - - #[test] - fn resolve_api_url_uses_env_var_when_explicit_empty() { - env::testing::with_var("MERGIFY_API_URL", Some("https://from-env.example/"), || { - let url = resolve_api_url(None).unwrap(); - assert_eq!(url.as_str(), "https://from-env.example/"); - }); - } - - #[test] - fn resolve_api_url_rejects_garbage() { - env::testing::with_var("MERGIFY_API_URL", None::<&str>, || { - let err = resolve_api_url(Some("not a url")).unwrap_err(); - assert!(err.to_string().contains("invalid --api-url")); - }); - } - - #[test] - fn resolve_repository_prefers_explicit() { - env::testing::with_var("GITHUB_REPOSITORY", Some("owner-from-env/repo"), || { - assert_eq!( - resolve_repository(Some("explicit/repo")).unwrap(), - "explicit/repo", - ); - }); - } - - #[test] - fn resolve_repository_falls_back_to_env() { - env::testing::with_var("GITHUB_REPOSITORY", Some("owner/repo"), || { - assert_eq!(resolve_repository(None).unwrap(), "owner/repo"); - }); - } - - #[test] - fn parse_slug_https_with_dot_git() { - assert_eq!( - parse_slug("https://github.com/owner/repo.git").as_deref(), - Some("owner/repo"), - ); - } - - #[test] - fn parse_slug_https_without_dot_git() { - assert_eq!( - parse_slug("https://github.com/owner/repo").as_deref(), - Some("owner/repo"), - ); - } - - #[test] - fn parse_slug_https_with_trailing_slash() { - assert_eq!( - parse_slug("https://github.com/owner/repo/").as_deref(), - Some("owner/repo"), - ); - } - - #[test] - fn parse_slug_ssh_form() { - assert_eq!( - parse_slug("git@github.com:owner/repo.git").as_deref(), - Some("owner/repo"), - ); - } - - #[test] - fn parse_slug_ssh_without_dot_git() { - assert_eq!( - parse_slug("git@github.com:owner/repo").as_deref(), - Some("owner/repo"), - ); - } - - #[test] - fn parse_slug_rejects_empty_owner() { - assert!(parse_slug("https://github.com//repo.git").is_none()); - } - - #[test] - fn parse_slug_rejects_path_without_repo() { - assert!(parse_slug("https://github.com/owner").is_none()); - } -} diff --git a/crates/mergify-core/src/command_context.rs b/crates/mergify-core/src/command_context.rs deleted file mode 100644 index 17179a4b..00000000 --- a/crates/mergify-core/src/command_context.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Per-command "resolved context" + Mergify HTTP client builder. -//! -//! Every Mergify-API command starts the same way: resolve the -//! repository slug, the bearer token, and the API URL via the -//! standard fallback chain (flag → env → `gh auth token` / git -//! remote / default), then build a typed [`HttpClient`] from them. -//! [`CommandContext`] bundles those three pieces with a -//! `mergify_client()` builder so the prelude shrinks from a four-line -//! ritual to two: -//! -//! ```ignore -//! let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; -//! let client = ctx.mergify_client()?; -//! ``` -//! -//! Specialized commands that don't fit the shape (`config validate` -//! needs no repository; `ci scopes-send` resolves the repo from CI -//! env; `config simulate` derives it from a PR URL) keep wiring up -//! the lower-level [`auth::resolve_*`] / [`HttpClient::new`] calls -//! by hand. -//! -//! [`auth::resolve_*`]: crate::auth - -use url::Url; - -use crate::auth; -use crate::error::CliError; -use crate::http::ApiFlavor; -use crate::http::Client as HttpClient; - -/// Resolved repository / token / API URL for a Mergify-API command. -pub struct CommandContext { - pub repository: String, - pub token: String, - pub api_url: Url, -} - -impl CommandContext { - /// Resolve all three pieces of context using the standard - /// fallback chain. Used by the `queue` and `freeze` command - /// families — every member of those groups needs the same - /// shape. - /// - /// # Errors - /// - /// Surfaces the first resolution failure as - /// [`CliError::Configuration`]. - pub fn resolve( - repository: Option<&str>, - token: Option<&str>, - api_url: Option<&str>, - ) -> Result { - // The API URL is resolved before the token because the - // token chain now consults the credential stored *for that - // URL*: one machine can hold a credential for the hosted - // service and one for an on-premise install. - let repository = auth::resolve_repository(repository)?; - let api_url = auth::resolve_api_url(api_url)?; - let token = auth::resolve_mergify_token(token, &api_url, auth::Audience::User)?; - Ok(Self { - repository, - token, - api_url, - }) - } - - /// Build a Mergify-flavored [`HttpClient`] from this context. - /// Clones the API URL so the [`CommandContext`] stays usable - /// afterwards — callers typically still need `self.repository` - /// to format URL paths. - pub fn mergify_client(&self) -> Result { - HttpClient::new(self.api_url.clone(), &self.token, ApiFlavor::Mergify) - } -} diff --git a/crates/mergify-core/src/credentials.rs b/crates/mergify-core/src/credentials.rs deleted file mode 100644 index 2f002edf..00000000 --- a/crates/mergify-core/src/credentials.rs +++ /dev/null @@ -1,1083 +0,0 @@ -//! Where the CLI keeps the Mergify credential `mergify auth login` -//! mints. -//! -//! Two backends, in this order: -//! -//! 1. **The OS keychain** — macOS Keychain, the Windows credential -//! manager, or a freedesktop Secret Service over D-Bus. -//! 2. **A `0600` JSON file** under the user's configuration -//! directory, used whenever the keychain is absent or refuses. -//! A container, a CI runner, or an SSH session on a headless box -//! has no D-Bus session at all, and `auth login` has to work -//! there — so a machine with no credential store falls back -//! silently. -//! -//! A keychain that *exists* and refuses is the one fatal case. `get` -//! reads the keychain before the file, so an entry left in it would -//! shadow whatever the fallback stored: `set` and `delete` verify -//! the keychain no longer holds the key, and refuse rather than -//! leave the user on a credential they think they replaced. "Locked" -//! and "empty" are told apart by [`KeychainState`], because a -//! read-back that cannot distinguish them verifies nothing. -//! -//! Entries are keyed by **API URL**, not by "the Mergify token": -//! one machine can legitimately hold a credential for the hosted -//! service and one for an on-premise install at the same time, and -//! each has to survive the other's `logout`. -//! -//! Nothing here logs a secret. The `Debug` impls below exist so -//! callers can trace *where* a credential came from without the -//! token itself reaching a log line. - -use std::collections::BTreeMap; -use std::fs; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; - -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use url::Url; - -use crate::error::CliError; - -/// Keychain service name every entry is filed under. The account -/// within it is the API URL. -const KEYRING_SERVICE: &str = "mergify-cli"; - -/// Directory under the platform config directory, and the file in -/// it. Named for its content so a future `config.json` next to it -/// stays obviously non-secret. -const CONFIG_SUBDIR: &str = "mergify"; -const CREDENTIALS_FILE: &str = "credentials.json"; - -/// A stored Mergify credential. -/// -/// `expires_at` is what the server said when it minted the token, -/// and it is advisory: a token can be revoked from the dashboard -/// long before it expires, so a client that trusts this field -/// instead of the API's answer will be wrong. It is here so -/// `auth status` can say when the credential runs out without a -/// round trip. -#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct Credential { - pub token: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expires_at: Option>, -} - -/// Hand-written, because a derived one prints the token. Every -/// `Debug` in this module exists to trace *where* a credential came -/// from, and the first `?credential` in a `tracing` call would have -/// put the secret itself in a log file. -impl std::fmt::Debug for Credential { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Credential") - .field("token", &"") - .field("expires_at", &self.expires_at) - .finish() - } -} - -/// Which backend a credential was read from or written to. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Location { - Keychain, - File(PathBuf), -} - -impl std::fmt::Display for Location { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Keychain => f.write_str("the system keychain"), - Self::File(path) => write!(f, "{}", path.display()), - } - } -} - -/// A credential together with the backend it came out of, so -/// `auth status` can tell the user where their secret actually -/// lives. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct StoredCredential { - pub credential: Credential, - pub location: Location, -} - -/// The two-backend credential store. -pub struct CredentialStore { - /// [`KeychainBackend::Off`] skips the keychain entirely, so a - /// unit test never reaches — or worse, writes to — the - /// developer's real keychain. - keychain: KeychainBackend, - /// `None` when the platform offers no configuration directory. - /// Only the *fallback* needs a path, so a machine without one - /// still uses its keychain — and that is exactly the kind of - /// machine that has one: a Windows service account, a systemd - /// unit with `ProtectHome=`, anything running without a home. - file: Option, -} - -impl CredentialStore { - /// The store the binary uses: OS keychain first, then a `0600` - /// file under the platform configuration directory. - /// - /// Infallible on purpose. A machine with no configuration - /// directory has no *fallback*, which is not the same as having - /// no store: failing here would refuse to read a keychain entry - /// that is sitting right there. - #[must_use] - pub fn discover() -> Self { - Self { - keychain: KeychainBackend::Os, - file: credentials_file(), - } - } - - /// A keychain-free store whose file lives directly at `path`. - /// Tests use it; so does any caller that has already decided - /// the keychain is not an option. - #[must_use] - pub fn file_at(path: PathBuf) -> Self { - Self { - keychain: KeychainBackend::Off, - file: Some(path), - } - } - - /// Read the credential stored for `api_url`, if there is one. - /// - /// Checks the keychain first and the file second, so a machine - /// that once fell back to the file and later gained a working - /// keychain reads the newer entry. - pub fn get(&self, api_url: &Url) -> Result, CliError> { - let key = key_for(api_url); - if let Some(raw) = self.keychain.get(&key) { - let credential = parse_secret(&raw)?; - return Ok(Some(StoredCredential { - credential, - location: Location::Keychain, - })); - } - let Some(file) = &self.file else { - return Ok(None); - }; - let Some(credential) = read_file(file)?.remove(&key) else { - return Ok(None); - }; - Ok(Some(StoredCredential { - credential, - location: Location::File(file.clone()), - })) - } - - /// Store `credential` for `api_url` and report where it landed. - /// - /// The backend that did *not* take it is cleared, so a machine - /// that logged in once without a keychain and once with it does - /// not leave the older secret readable on disk. - /// - /// # Errors - /// - /// [`CliError::Configuration`] when neither backend is - /// available: no keychain answered and there is nowhere to put a - /// file. - pub fn set(&self, api_url: &Url, credential: &Credential) -> Result { - let key = key_for(api_url); - let secret = serde_json::to_string(credential) - .map_err(|e| CliError::wrap("serialize the credential", e))?; - if self.keychain.enabled() && self.keychain.set(&key, &secret) { - // Best effort: the credential is safely in the keychain, - // which `get` reads first, so a stale file copy is - // shadowed rather than dangerous. Failing here — a - // corrupt `credentials.json` makes this error — would - // report a failed login that in fact stored a token, and - // every retry would mint another one nobody revokes. - if let Err(e) = self.remove_from_file(&key) { - tracing::debug!(error = %e, "could not clear the fallback credential file"); - } - return Ok(Location::Keychain); - } - // The keychain refused the write, so the fallback has to - // take it. Everything that can refuse the fallback is - // resolved *before* the stale keychain entry is deleted: a - // delete followed by a failed write would leave the machine - // with no credential at all, and `login` revokes the token - // it just minted on this error path — so the user would end - // up logged out of a machine that was working a second ago. - let Some(file) = &self.file else { - return Err(CliError::Configuration( - "no system keychain answered, and this machine offers no configuration \ - directory to store the credential in — check that your user has a home \ - directory" - .to_string(), - )); - }; - let mut entries = read_file(file)?; - // If the keychain is nevertheless holding an older entry for - // this URL, writing the fallback would store a credential - // that can never be read: `get` checks the keychain first and - // would keep answering with the stale one. So read back - // rather than trust the delete, and refuse unless that - // read-back is conclusive: "the store refused" is not "there - // is nothing there". - if self.keychain.enabled() { - self.keychain.delete(&key); - match self.keychain.probe(&key) { - KeychainState::Absent => {} - KeychainState::Present => { - return Err(CliError::Configuration(format!( - "an older credential for {api_url} is in the system keychain and \ - could not be replaced or removed. Unlock your keychain and try \ - again.", - ))); - } - KeychainState::Unknown => { - return Err(CliError::Configuration(format!( - "the system keychain refused both the write and a read-back, so an \ - older credential for {api_url} may still be in it — and one there \ - would shadow anything stored on disk. Unlock your keychain and try \ - again.", - ))); - } - } - } - entries.insert(key.clone(), credential.clone()); - write_file(file, &entries)?; - Ok(Location::File(file.clone())) - } - - /// Forget the credential for `api_url` in both backends. - /// Returns whether either of them held one. - /// - /// # Errors - /// - /// [`CliError::Configuration`] when the keychain still holds - /// the credential afterwards, or will not say. A keychain locked - /// against a delete reports the same `false` as one that had - /// nothing, so the entry is read back rather than the delete - /// believed — and a read-back that cannot answer is an error - /// too, because `logout` promises a machine that no longer - /// carries the credential. - pub fn delete(&self, api_url: &Url) -> Result { - let key = key_for(api_url); - // The keychain first, and its verdict held back until the - // file has been tried too. The two backends fail - // independently: a corrupt `credentials.json` used to abort - // the whole call before the keychain was touched, which made - // `logout` a permanent no-op against the one backend that - // actually held the credential. - let mut from_keychain = false; - let mut keychain_verdict = Ok(()); - if self.keychain.enabled() { - from_keychain = self.keychain.delete(&key); - keychain_verdict = match self.keychain.probe(&key) { - KeychainState::Absent => Ok(()), - KeychainState::Present => Err(CliError::Configuration(format!( - "the credential for {api_url} is still in the system keychain: it could \ - not be removed. Unlock your keychain and run `mergify auth logout` \ - again.", - ))), - KeychainState::Unknown => Err(CliError::Configuration(format!( - "the system keychain refused a read-back, so the credential for \ - {api_url} may still be in it. Unlock your keychain and run `mergify \ - auth logout` again.", - ))), - }; - } - let from_file = self.remove_from_file(&key); - // The keychain's verdict first: a credential still live in it - // outranks a file this call could not rewrite. Then the - // file's own error -- not folded into the `||` below, where - // short-circuiting on a successful keychain delete would - // swallow it. - keychain_verdict?; - let from_file = from_file?; - Ok(from_keychain || from_file) - } - - /// Path of the file backend, or `None` when this machine has no - /// configuration directory to put one in. - #[must_use] - pub fn file_path(&self) -> Option<&Path> { - self.file.as_deref() - } - - fn remove_from_file(&self, key: &str) -> Result { - let Some(file) = &self.file else { - return Ok(false); - }; - let mut entries = read_file(file)?; - if entries.remove(key).is_none() { - return Ok(false); - } - write_file(file, &entries)?; - Ok(true) - } -} - -fn read_file(file: &Path) -> Result, CliError> { - let raw = match fs::read(file) { - Ok(raw) => raw, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()), - Err(e) => { - return Err(CliError::wrap(format!("read {}", file.display()), e)); - } - }; - serde_json::from_slice(&raw).map_err(|e| { - // Not "you are logged out": a corrupt store would send - // the user round `auth login` forever without ever - // saying which file to look at. - CliError::Configuration(format!( - "{} is not valid credential JSON ({e}). Delete it and run `mergify auth login` again.", - file.display(), - )) - }) -} - -fn write_file(file: &Path, entries: &BTreeMap) -> Result<(), CliError> { - if let Some(parent) = file.parent() { - fs::create_dir_all(parent) - .map_err(|e| CliError::wrap(format!("create {}", parent.display()), e))?; - restrict_to_owner(parent, 0o700)?; - } - // An empty store is a deleted file rather than `{}`: leaving - // an empty JSON object behind reads, to anyone auditing the - // machine, like a credential that failed to load. - if entries.is_empty() { - match fs::remove_file(file) { - Ok(()) => return Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => { - return Err(CliError::wrap(format!("remove {}", file.display()), e)); - } - } - } - let rendered = serde_json::to_vec_pretty(entries) - .map_err(|e| CliError::wrap("serialize the credential store", e))?; - // Each write is atomic (temp file + rename) but the surrounding - // read-modify-write is not locked, so two `auth` commands racing - // on *different* API URLs can lose one entry. Not fixed here: the - // writers are `auth login` and `auth logout`, both driven by a - // person at a terminal, and a lock file is its own design — stale - // locks, no-home machines, Windows semantics. It is a real hole, - // just a narrow one. - // Written to a sibling temp file and renamed, so a crash - // mid-write cannot truncate a store that holds a second - // deployment's credential. `NamedTempFile` creates at 0600 - // on Unix; the explicit chmod covers the umask-independent - // guarantee and documents the intent. - let mut tmp = tempfile::NamedTempFile::new_in( - file.parent() - .ok_or_else(|| CliError::Configuration("credential path has no parent".into()))?, - ) - .map_err(|e| CliError::wrap("create a temporary credential file", e))?; - restrict_to_owner(tmp.path(), 0o600)?; - tmp.write_all(&rendered) - .map_err(|e| CliError::wrap("write the credential store", e))?; - tmp.flush() - .map_err(|e| CliError::wrap("flush the credential store", e))?; - tmp.persist(file) - .map_err(|e| CliError::wrap(format!("write {}", file.display()), e.error))?; - Ok(()) -} - -/// Restrict `path` to its owner. A no-op off Unix, where the file -/// inherits the ACL of the user's profile directory and there is no -/// mode to set. -#[cfg(unix)] -fn restrict_to_owner(path: &Path, mode: u32) -> Result<(), CliError> { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .map_err(|e| CliError::wrap(format!("restrict permissions on {}", path.display()), e)) -} - -#[cfg(not(unix))] -fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<(), CliError> { - Ok(()) -} - -/// The account name an API URL is filed under, in both backends. -/// -/// `Url::as_str` normalizes the scheme, the case, and the default -/// port, but not a trailing slash on the path — and a trailing slash -/// is not a different deployment. The HTTP client gives its base URL -/// a trailing slash and strips the request path's leading one before -/// joining, so `https://host/api` and `https://host/api/` both send -/// `/v1/user` to `https://host/api/v1/user`. Two entries for them -/// would tell a user who logged in with one spelling that they are -/// not logged in with the other. -fn key_for(api_url: &Url) -> String { - let raw = api_url.as_str(); - raw.strip_suffix('/').unwrap_or(raw).to_string() -} - -fn parse_secret(raw: &str) -> Result { - serde_json::from_str(raw).map_err(|e| { - CliError::Configuration(format!( - "the credential in the system keychain is not valid JSON ({e}). \ - Run `mergify auth logout` then `mergify auth login` again.", - )) - }) -} - -/// What the keychain says about one entry, when the answer has to -/// be trusted. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum KeychainState { - /// The entry is there. - Present, - /// Nothing is there to read — either the store said so, or this - /// machine has no credential store at all, which for a caller - /// asking "could an entry here shadow the file?" is the same - /// answer. - Absent, - /// A store exists and refused to answer: locked, or a user who - /// clicked Deny. Nothing can be concluded from that. - Unknown, -} - -/// The keychain half of the store. -/// -/// An enum rather than four free functions because the branches that -/// decide whether a failed `login` is *fatal* are the delicate part -/// of this module, and they are unreachable in a test that cannot -/// stand a keychain up. `Fake` is the seam; it exists only under -/// `cfg(test)`, so the shipped binary has the two real arms. -enum KeychainBackend { - /// The platform credential store, via `keyring`. - Os, - /// No keychain: what a file-only caller asks for. - Off, - #[cfg(any(test, feature = "test-support"))] - Fake(std::sync::Mutex), -} - -impl KeychainBackend { - /// Whether the keychain is consulted at all. `false` skips every - /// keychain leg, including the verification ones. - fn enabled(&self) -> bool { - !matches!(self, Self::Off) - } - - /// Read one entry: `Ok(None)` is "nothing filed here", `Err` is - /// the store declining to answer. - fn read(&self, key: &str) -> Result, keyring::Error> { - match self { - Self::Os => { - match keyring::Entry::new(KEYRING_SERVICE, key).and_then(|e| e.get_password()) { - Ok(secret) => Ok(Some(secret)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(e), - } - } - // Consistently "there is no store here", so `get` - // answers `None`, `probe` answers `Absent`, and the two - // verification branches cannot fire on a file-only store. - Self::Off => Err(keyring::Error::NoDefaultStore), - #[cfg(any(test, feature = "test-support"))] - Self::Fake(fake) => fake.lock().unwrap().read(), - } - } - - fn write(&self, key: &str, secret: &str) -> Result<(), keyring::Error> { - match self { - Self::Os => { - keyring::Entry::new(KEYRING_SERVICE, key).and_then(|e| e.set_password(secret)) - } - Self::Off => Err(keyring::Error::NoDefaultStore), - #[cfg(any(test, feature = "test-support"))] - Self::Fake(fake) => fake.lock().unwrap().write(secret), - } - } - - /// Remove one entry, reporting whether one was there. - fn remove(&self, key: &str) -> Result { - match self { - Self::Os => match keyring::Entry::new(KEYRING_SERVICE, key) - .and_then(|e| e.delete_credential()) - { - Ok(()) => Ok(true), - Err(keyring::Error::NoEntry) => Ok(false), - Err(e) => Err(e), - }, - Self::Off => Err(keyring::Error::NoDefaultStore), - #[cfg(any(test, feature = "test-support"))] - Self::Fake(fake) => fake.lock().unwrap().remove(), - } - } - - /// Read one entry for the *read path*. Every failure — no entry, - /// no keychain, a locked one, a user who clicked Deny — - /// collapses to `None`: the caller's next move is the file - /// backend either way, and the distinction is only worth a debug - /// line. - fn get(&self, key: &str) -> Option { - match self.read(key) { - Ok(secret) => secret, - Err(e) => { - tracing::debug!(error = %e, "no credential read from the system keychain"); - None - } - } - } - - /// Write one entry, reporting whether it took. `false` means the - /// caller falls back to the file. - fn set(&self, key: &str, secret: &str) -> bool { - match self.write(key, secret) { - Ok(()) => true, - Err(e) => { - tracing::debug!(error = %e, "could not store the credential in the system keychain"); - false - } - } - } - - /// Delete one entry, reporting whether one was there. A store - /// that refused reports the same `false` as an empty one, which - /// is why every caller follows this with [`Self::probe`]. - fn delete(&self, key: &str) -> bool { - match self.remove(key) { - Ok(removed) => removed, - Err(e) => { - tracing::debug!( - error = %e, - "could not delete the credential from the system keychain" - ); - false - } - } - } - - /// Ask about `key`, keeping the three answers apart. - /// - /// [`Self::get`] collapses every failure to `None` because its - /// caller reaches for the file either way. The two verification - /// points cannot afford that: they are deciding whether an entry - /// `get` reads *first* is about to shadow what they just wrote, - /// and "the store refused" is not "there is nothing there". - fn probe(&self, key: &str) -> KeychainState { - match self.read(key) { - Ok(Some(_)) => KeychainState::Present, - Ok(None) => KeychainState::Absent, - Err(e) => keychain_state_for(&e), - } - } -} - -/// Split out from [`KeychainBackend::probe`] so the mapping that -/// decides whether a failed login is fatal is testable on its own. -fn keychain_state_for(error: &keyring::Error) -> KeychainState { - match error { - // `NoEntry` is the store answering "nothing filed here". - // - // `NoDefaultStore` is `keyring` failing to *initialize* a - // store, which it caches process-wide in a `LazyLock`. That - // covers both "this machine has no credential store" — a - // container, a systemd unit, an SSH session with no D-Bus, - // the case the whole file fallback exists for — and "the - // store is there but could not be reached just now". The - // crate does not tell the two apart (`Entry::store_status()` - // separates only an unsupported platform), and they want - // opposite answers, so this picks the one whose failure is - // recoverable: treating it as `Unknown` would make - // `auth login` fatal on every headless machine, while - // treating it as `Absent` costs a desktop whose session bus - // blinked one stale-credential 403 that the next - // `auth login` clears. - keyring::Error::NoEntry | keyring::Error::NoDefaultStore => KeychainState::Absent, - _ => { - tracing::debug!(error = %error, "could not verify the system keychain entry"); - KeychainState::Unknown - } - } -} - -#[cfg(any(test, feature = "test-support"))] -impl CredentialStore { - /// A store whose keychain is [`FakeKeychain`], so the branches - /// that decide whether a login is fatal can be exercised. - fn with_fake(fake: FakeKeychain, file: Option) -> Self { - Self { - keychain: KeychainBackend::Fake(std::sync::Mutex::new(fake)), - file, - } - } -} - -#[cfg(test)] -impl CredentialStore { - /// What the fake keychain is holding, for a test that has to - /// assert an entry survived — or did not. - fn fake_entry(&self) -> Option { - match &self.keychain { - KeychainBackend::Fake(fake) => fake.lock().unwrap().entry.clone(), - _ => panic!("not a fake keychain"), - } - } -} - -/// Stores that model one specific way a real keychain misbehaves, -/// for a **caller's** tests — `auth logout`'s "removed but never -/// read" branch is unreachable with a keychain-free store, and -/// unreachable code is code an edit can break with the suite green. -/// -/// Behind the `test-support` feature, which nothing but a -/// dev-dependency turns on. -#[cfg(feature = "test-support")] -impl CredentialStore { - /// A store holding `secret` in a keychain that refuses every - /// *read* while still deleting on request. That is macOS with a - /// denied read ACL: deleting an item needs no decrypt, so - /// `logout` removes a credential it never saw and has nothing to - /// revoke. - #[must_use] - pub fn with_unreadable_keychain(secret: &str) -> Self { - Self::with_fake( - FakeKeychain { - entry: Some(secret.to_string()), - refuse_reads: true, - ..FakeKeychain::default() - }, - None, - ) - } -} - -/// A keychain that can be locked, denied, or emptied on demand. -/// -/// Models the three ways a real one misbehaves independently, -/// because they are independent in practice: macOS will happily -/// delete an item whose read ACL it refuses (deleting needs no -/// decrypt), and a locked store refuses reads and writes while -/// still existing. -#[cfg(any(test, feature = "test-support"))] -#[derive(Default)] -struct FakeKeychain { - entry: Option, - refuse_reads: bool, - refuse_writes: bool, - refuse_deletes: bool, -} - -#[cfg(any(test, feature = "test-support"))] -impl FakeKeychain { - fn locked(error: &str) -> keyring::Error { - keyring::Error::NoStorageAccess(error.into()) - } - - fn read(&self) -> Result, keyring::Error> { - if self.refuse_reads { - return Err(Self::locked("the keychain refused the read")); - } - Ok(self.entry.clone()) - } - - fn write(&mut self, secret: &str) -> Result<(), keyring::Error> { - if self.refuse_writes { - return Err(Self::locked("the keychain refused the write")); - } - self.entry = Some(secret.to_string()); - Ok(()) - } - - fn remove(&mut self) -> Result { - if self.refuse_deletes { - return Err(Self::locked("the keychain refused the delete")); - } - Ok(self.entry.take().is_some()) - } -} - -/// Where the file backend lives, or `None` on a machine that gives -/// no configuration directory — which on every supported platform -/// means the environment does not name a home. -fn credentials_file() -> Option { - let Some(dir) = dirs::config_dir() else { - tracing::debug!("no configuration directory; the credential store is keychain-only"); - return None; - }; - Some(dir.join(CONFIG_SUBDIR).join(CREDENTIALS_FILE)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn store(dir: &tempfile::TempDir) -> CredentialStore { - CredentialStore::file_at(dir.path().join("mergify").join("credentials.json")) - } - - fn url(raw: &str) -> Url { - Url::parse(raw).unwrap() - } - - fn credential(token: &str) -> Credential { - Credential { - token: token.to_string(), - expires_at: None, - } - } - - #[test] - fn get_returns_none_when_nothing_is_stored() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - assert_eq!(store.get(&url("https://api.mergify.com")).unwrap(), None); - } - - #[test] - fn set_then_get_round_trips_through_the_file() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - let api = url("https://api.mergify.com"); - let location = store.set(&api, &credential("mut_secret")).unwrap(); - assert_eq!( - location, - Location::File(store.file_path().unwrap().to_path_buf()) - ); - - let found = store.get(&api).unwrap().unwrap(); - assert_eq!(found.credential, credential("mut_secret")); - assert_eq!( - found.location, - Location::File(store.file_path().unwrap().into()) - ); - } - - #[test] - fn expiry_survives_the_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - let api = url("https://api.mergify.com"); - let expires_at = DateTime::parse_from_rfc3339("2027-01-02T03:04:05Z") - .unwrap() - .with_timezone(&Utc); - let credential = Credential { - token: "mut_secret".to_string(), - expires_at: Some(expires_at), - }; - store.set(&api, &credential).unwrap(); - assert_eq!(store.get(&api).unwrap().unwrap().credential, credential); - } - - // The whole point of keying by API URL: a laptop that talks to - // SaaS and to an on-premise install holds both, and neither - // login overwrites the other. - #[test] - fn credentials_are_keyed_by_api_url() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - let saas = url("https://api.mergify.com"); - let onprem = url("https://mergify.internal.example/api"); - store.set(&saas, &credential("mut_saas")).unwrap(); - store.set(&onprem, &credential("mut_onprem")).unwrap(); - - assert_eq!( - store.get(&saas).unwrap().unwrap().credential.token, - "mut_saas", - ); - assert_eq!( - store.get(&onprem).unwrap().unwrap().credential.token, - "mut_onprem", - ); - - assert!(store.delete(&saas).unwrap()); - assert_eq!(store.get(&saas).unwrap(), None); - assert_eq!( - store.get(&onprem).unwrap().unwrap().credential.token, - "mut_onprem", - "deleting one deployment's credential must not touch another's", - ); - } - - // `https://api.mergify.com` and `https://api.mergify.com/` are - // the same deployment, and `--api-url` accepts both spellings. - // `Url` normalizes this pair on its own, which is why the - // on-premise pair below is the one that actually pins `key_for`. - #[test] - fn url_spellings_resolve_to_one_entry() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - store - .set(&url("https://api.mergify.com"), &credential("mut_one")) - .unwrap(); - assert_eq!( - store - .get(&url("https://api.mergify.com/")) - .unwrap() - .unwrap() - .credential - .token, - "mut_one", - ); - } - - // The pair `Url` does *not* normalize, and the one an - // on-premise deployment is actually spelled with. Both bases - // reach the same endpoint, because every request joins an - // absolute path over them. - #[test] - fn a_trailing_slash_on_the_path_is_the_same_deployment() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - store - .set( - &url("https://mergify.internal.example/api"), - &credential("mut_onprem"), - ) - .unwrap(); - assert_eq!( - store - .get(&url("https://mergify.internal.example/api/")) - .unwrap() - .unwrap() - .credential - .token, - "mut_onprem", - ); - } - - #[test] - fn delete_reports_whether_anything_was_stored() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - let api = url("https://api.mergify.com"); - assert!(!store.delete(&api).unwrap()); - store.set(&api, &credential("mut_secret")).unwrap(); - assert!(store.delete(&api).unwrap()); - assert_eq!(store.get(&api).unwrap(), None); - } - - // An emptied store leaves no file behind, so `{}` on disk never - // has to be told apart from a credential that failed to load. - #[test] - fn deleting_the_last_credential_removes_the_file() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - let api = url("https://api.mergify.com"); - store.set(&api, &credential("mut_secret")).unwrap(); - assert!(store.file_path().unwrap().exists()); - store.delete(&api).unwrap(); - assert!(!store.file_path().unwrap().exists()); - } - - #[cfg(unix)] - #[test] - fn the_file_and_its_directory_are_owner_only() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - store - .set(&url("https://api.mergify.com"), &credential("mut_secret")) - .unwrap(); - - let file_mode = fs::metadata(store.file_path().unwrap()) - .unwrap() - .permissions() - .mode(); - assert_eq!(file_mode & 0o777, 0o600, "got {file_mode:o}"); - let dir_mode = fs::metadata(store.file_path().unwrap().parent().unwrap()) - .unwrap() - .permissions() - .mode(); - assert_eq!(dir_mode & 0o777, 0o700, "got {dir_mode:o}"); - } - - // The state the lazy file path creates: no configuration - // directory, and a keychain that did not take the credential - // either. Reading is a clean "nothing stored"; writing has to - // say why it cannot. - #[test] - fn a_store_with_no_backend_reads_empty_and_refuses_to_write() { - let store = CredentialStore { - keychain: KeychainBackend::Off, - file: None, - }; - let api = url("https://api.mergify.com"); - assert_eq!(store.get(&api).unwrap(), None); - assert!(!store.delete(&api).unwrap()); - - let err = store.set(&api, &credential("mut_secret")).unwrap_err(); - assert!( - err.to_string().contains("no configuration directory"), - "got {err}", - ); - } - - #[test] - fn a_corrupt_store_is_an_error_not_an_empty_one() { - let dir = tempfile::tempdir().unwrap(); - let store = store(&dir); - fs::create_dir_all(store.file_path().unwrap().parent().unwrap()).unwrap(); - fs::write(store.file_path().unwrap(), b"{ this is not json").unwrap(); - - let err = store.get(&url("https://api.mergify.com")).unwrap_err(); - let message = err.to_string(); - assert!(message.contains("credentials.json"), "got {message:?}"); - assert!(message.contains("auth login"), "got {message:?}"); - } - - #[test] - fn debug_never_prints_the_token() { - let stored = StoredCredential { - credential: credential("mut_secret"), - location: Location::Keychain, - }; - - // Both shapes, because `StoredCredential` derives `Debug` - // and would print the token through the inner one. - let rendered = format!("{:?} {:?}", stored.credential, stored); - assert!( - !rendered.contains("mut_secret"), - "the token reached debug output: {rendered}", - ); - assert!(rendered.contains(""), "got {rendered}"); - } - - #[test] - fn a_machine_with_no_credential_store_reads_as_absent() { - // The container / headless-SSH case. Classified `Absent` so - // the file fallback stays non-fatal: there is no store to - // hold an entry that could shadow it. - assert_eq!( - keychain_state_for(&keyring::Error::NoDefaultStore), - KeychainState::Absent, - ); - assert_eq!( - keychain_state_for(&keyring::Error::NoEntry), - KeychainState::Absent, - ); - } - - #[test] - fn a_keychain_that_refuses_to_answer_is_not_read_as_empty() { - // The bug this mapping exists for: a locked keychain, or a - // user who clicked Deny, answers with an error — and a - // read-back that called that "empty" would report a - // credential removed while it is still there. - for error in [ - keyring::Error::NoStorageAccess("locked".into()), - keyring::Error::PlatformFailure("dbus went away".into()), - ] { - assert_eq!(keychain_state_for(&error), KeychainState::Unknown); - } - } - - /// The stored form of a credential, as the keychain holds it. - fn secret(token: &str) -> String { - serde_json::to_string(&credential(token)).unwrap() - } - - #[test] - fn a_refused_write_over_a_stale_entry_refuses_the_login() { - let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::with_fake( - FakeKeychain { - entry: Some(secret("mut_old")), - refuse_writes: true, - refuse_deletes: true, - ..FakeKeychain::default() - }, - Some(dir.path().join("mergify").join("credentials.json")), - ); - let api = url("https://api.mergify.com"); - - let err = store.set(&api, &credential("mut_new")).unwrap_err(); - assert!(err.to_string().contains("older credential"), "got {err}"); - // Nothing may reach the file: `get` reads the keychain first, - // so a file copy written here could never be read. - assert!(!store.file_path().unwrap().exists()); - } - - #[test] - fn a_refused_write_with_an_unreadable_keychain_refuses_the_login() { - let dir = tempfile::tempdir().unwrap(); - let store = CredentialStore::with_fake( - FakeKeychain { - entry: Some(secret("mut_old")), - refuse_reads: true, - refuse_writes: true, - refuse_deletes: true, - }, - Some(dir.path().join("mergify").join("credentials.json")), - ); - - let err = store - .set(&url("https://api.mergify.com"), &credential("mut_new")) - .unwrap_err(); - assert!(err.to_string().contains("read-back"), "got {err}"); - } - - #[test] - fn a_refused_write_keeps_the_keychain_entry_when_there_is_nowhere_to_fall_back() { - // The delete used to run before the fallback was known to be - // available, so this machine ended up with no credential at - // all -- and `login` revokes the new token on this error. - let store = CredentialStore::with_fake( - FakeKeychain { - entry: Some(secret("mut_old")), - refuse_writes: true, - ..FakeKeychain::default() - }, - None, - ); - - let err = store - .set(&url("https://api.mergify.com"), &credential("mut_new")) - .unwrap_err(); - assert!( - err.to_string().contains("no configuration directory"), - "got {err}", - ); - assert_eq!(store.fake_entry(), Some(secret("mut_old"))); - } - - #[test] - fn logout_clears_the_keychain_even_when_the_credential_file_is_corrupt() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("mergify").join("credentials.json"); - fs::create_dir_all(file.parent().unwrap()).unwrap(); - fs::write(&file, b"{ this is not json").unwrap(); - let store = CredentialStore::with_fake( - FakeKeychain { - entry: Some(secret("mut_stored")), - ..FakeKeychain::default() - }, - Some(file), - ); - - // The file leg still reports its own failure... - let err = store.delete(&url("https://api.mergify.com")).unwrap_err(); - assert!(err.to_string().contains("credentials.json"), "got {err}"); - // ...but the keychain, which is where the credential actually - // was, has been cleared rather than skipped. - assert_eq!(store.fake_entry(), None); - } - - #[test] - fn a_keychain_write_clears_the_file_copy() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("mergify").join("credentials.json"); - let api = url("https://api.mergify.com"); - CredentialStore::file_at(file.clone()) - .set(&api, &credential("mut_on_disk")) - .unwrap(); - - let store = CredentialStore::with_fake(FakeKeychain::default(), Some(file.clone())); - assert_eq!( - store.set(&api, &credential("mut_new")).unwrap(), - Location::Keychain - ); - - assert_eq!(store.fake_entry(), Some(secret("mut_new"))); - // The emptied store is removed outright, so there is no - // readable copy left behind on disk. - assert!(!file.exists()); - } -} diff --git a/crates/mergify-core/src/env.rs b/crates/mergify-core/src/env.rs deleted file mode 100644 index 1c837d44..00000000 --- a/crates/mergify-core/src/env.rs +++ /dev/null @@ -1,508 +0,0 @@ -//! The one place *this workspace* reads the process environment, and -//! the only way a test changes what it says. -//! -//! Not the only place the process reads it: `tracing-subscriber` -//! reads `RUST_LOG`, `dirs` reads `HOME` / `XDG_CONFIG_HOME` / -//! `APPDATA` under `credentials_file()`, and `reqwest` reads -//! `HTTP_PROXY` / `NO_PROXY` when it builds a client. Those are -//! inside dependencies, so neither the lint nor a test overlay -//! reaches them: overlaying `HOME` compiles, runs, and changes -//! nothing. -//! -//! # Why this is not `std::env` -//! -//! Tests used to set up environment-dependent behaviour with -//! `temp_env`, which mutates the process environment for the duration -//! of a closure. `setenv` is `unsafe` since Rust 1.80 for a concrete -//! reason: on Unix it can reallocate `environ` while another thread is -//! inside `getenv`, and that is a use-after-free, not a race whose -//! worst case is a wrong value. A libtest binary runs its tests on -//! many threads at once, and the concurrent reader does not have to be -//! ours — `std::env::temp_dir` (every `tempfile::tempdir()`), -//! `Command::spawn` building a child's environment, and any dependency -//! that consults the environment are all `getenv` on another thread. -//! `temp_env`'s own lock cannot serialise against any of them; it only -//! serialises against other `temp_env` calls. -//! -//! So nothing in this workspace mutates the process environment, in -//! tests or anywhere else. Production reads it through [`var`], -//! [`var_os`] and [`var_non_empty`]; a test replaces what those return -//! with [`testing::with_vars`], which touches no global state at all. -//! -//! # What a test overlay does -//! -//! [`testing::with_vars`] installs a **replacement** environment on the -//! current thread. While it is installed, the process environment is -//! invisible: a name the test did not list reads as unset. That is the -//! point — it is what makes a test that reads `GITHUB_ACTIONS` behave -//! the same on a laptop and on a GitHub Actions runner, without a -//! hand-maintained list of variables to scrub first. -//! -//! The overlay covers *our* reads, and only ours. A dependency that -//! consults the environment (`dirs`, `keyring`, `reqwest`'s proxy -//! variables) and any process we spawn both see the real one, and -//! they see it silently — an overlay that sets `HOME` or `PATH` -//! changes nothing for them. Setting a variable for a child is a -//! different job with a different tool: `Command::env`, which touches -//! no shared state and is not affected by any of this. -//! -//! Keys match exactly. On Windows the real environment does not — -//! `std::env::var_os("PATH")` finds a `Path` — so an overlay on -//! `Path` leaves `var("PATH")` reading the host's. Nothing here reads -//! a name in a case other than the one it writes, so no call site can -//! tell the difference, and matching the platform would mean a -//! Windows-only normalisation that this suite never runs: the unit -//! tests are a Linux and macOS job, Windows only builds the binary. -//! -//! The overlay is thread-local, and it is compiled into release builds -//! rather than gated behind `cfg(test)`. `cfg(test)` cannot do the job: -//! it is false whenever this crate is compiled as a dependency, so -//! every consumer crate's tests would read the real environment -//! anyway. `mergify_tui::theme` carries the same note for the same -//! reason. The cost is one thread-local read per environment lookup on -//! a path that runs a handful of times per process. -//! -//! # The empty-string rule -//! -//! The CLI's resolver pattern (`flag → env → default`) treats the -//! empty string as "not set", because callers in the wild — most -//! notably the `gha-mergify-ci` GitHub Action — `export VAR=""` -//! when no value is available. Inlining -//! `var(NAME).filter(|s| !s.is_empty())` on every call site looks -//! innocuous but invites the same bug we've now hit twice -//! (monorepo#33423, `MERGIFY_CONFIG_PATH` and -//! `MERGIFY_TEST_EXIT_CODE`): a contributor adds clap's -//! `env = "MERGIFY_FOO"` attribute on a flag instead, and clap's -//! parser treats an empty env value as a present-but-empty flag -//! value, aborting parsing before any of our code can fall back. -//! -//! Use [`var_non_empty`] for the env-var leg of any `flag → env -//! → default` chain. Do **not** wire env vars through clap's -//! `env = ...` attribute for any of the `MERGIFY_*` namespace. - -use std::cell::Cell; -use std::cell::RefCell; -use std::collections::HashMap; -use std::ffi::OsStr; -use std::ffi::OsString; - -thread_local! { - /// The replacement environment installed by - /// [`testing::with_vars`], or `None` when the real process - /// environment is in effect. `Some(map)` is exhaustive: names - /// absent from `map` read as unset. - static OVERLAY: RefCell>> = const { RefCell::new(None) }; - - /// How many overlay scopes are open on this thread. Each scope - /// records the value it pushed and checks it again on the way - /// out, so two overlapping scopes are a panic rather than a - /// wrong answer — see `testing::Restore`'s `Drop`. - static DEPTH: Cell = const { Cell::new(0) }; -} - -/// Read an environment variable as an `OsString`. -/// -/// Returns the test overlay's value when one is installed on this -/// thread (see the module docs), otherwise the process environment's. -#[must_use] -pub fn var_os(name: &str) -> Option { - // `try_with`, not `with`: `OVERLAY` owns a `HashMap` and so - // registers a thread-local destructor, and `with` panics once - // that has run. A `Drop` impl reading a variable while its thread - // tears down would abort the process, which `std::env::var_os` - // never did. No overlay can exist at that point anyway. - OVERLAY - .try_with(|slot| { - slot.borrow().as_ref().map(|map| { - // An installed overlay *is* the environment: a name - // it does not carry reads as unset. - map.get(OsStr::new(name)).cloned() - }) - }) - .unwrap_or(None) - .unwrap_or_else(|| { - // The single sanctioned process-environment read in - // the workspace: every other caller goes through the - // functions above. - #[allow(clippy::disallowed_methods)] - std::env::var_os(name) - }) -} - -/// Read an environment variable as a `String`. -/// -/// A value that is not valid UTF-8 reads as unset, matching -/// `std::env::var(name).ok()`. -#[must_use] -pub fn var(name: &str) -> Option { - var_os(name).and_then(|value| value.into_string().ok()) -} - -/// [`var_non_empty`] for a value that need not be UTF-8 — the editor -/// chain (`GIT_EDITOR` / `VISUAL` / `EDITOR`) hands its value -/// straight to a `Command`, so decoding it would only be to re-encode -/// it. -#[must_use] -pub fn var_os_non_empty(name: &str) -> Option { - var_os(name).filter(|value| !value.is_empty()) -} - -/// Read an environment variable and return its value if it's set -/// to a non-empty string. Unset or empty both collapse to `None`. -/// -/// This is the standard primitive for the env-var leg of the -/// `--flag → env → default` resolver chain across every ported -/// command. See the module doc for the empty-as-unset rationale. -#[must_use] -pub fn var_non_empty(name: &str) -> Option { - var(name).filter(|value| !value.is_empty()) -} - -/// Give a test environment-dependent behaviour without mutating the -/// process environment. -/// -/// Compiled unconditionally, not behind `cfg(test)` or a feature — -/// see the module docs for why that is the only thing that works. -pub mod testing { - use std::collections::HashMap; - use std::ffi::OsStr; - use std::ffi::OsString; - use std::future::Future; - use std::marker::PhantomData; - use std::rc::Rc; - - use super::DEPTH; - use super::OVERLAY; - - /// Restores the enclosing overlay (usually `None`) when the scope - /// ends, including while a panic unwinds. - /// - /// Deliberately `!Send` (the `Rc` marker). The overlay lives on - /// the thread that installed it, so a guard that reached another - /// thread would restore the wrong thread's slot and strand the - /// overlay on the original one — permanently, since nothing else - /// clears it. Making the guard unsendable makes the future - /// returned by [`with_vars_async`] unsendable too, so - /// `tokio::spawn`-ing it is a compile error rather than a silent - /// leak. - struct Restore { - previous: Option>, - /// The value this scope pushed onto `DEPTH`, checked again on - /// the way out. - depth: u64, - _unsend: PhantomData>, - } - - impl Restore { - /// Make `map` this thread's environment until the returned - /// guard drops. - fn install_map(map: HashMap) -> Self { - let depth = DEPTH.with(|open| { - let pushed = open.get() + 1; - open.set(pushed); - pushed - }); - Self { - previous: OVERLAY.with_borrow_mut(|slot| slot.replace(map)), - depth, - _unsend: PhantomData, - } - } - } - - impl Drop for Restore { - fn drop(&mut self) { - // Ignore a thread already past its destructors: there is - // nothing left to restore, and panicking in a `Drop` - // during unwinding aborts. - let _ = OVERLAY.try_with(|slot| *slot.borrow_mut() = self.previous.take()); - let Ok(innermost) = DEPTH.try_with(|open| { - let innermost = open.get(); - open.set(self.depth - 1); - innermost - }) else { - return; - }; - // Never report while already unwinding: a panic inside a - // `Drop` during a panic aborts the process. - if std::thread::panicking() { - return; - } - // Scopes have to close in the order they opened, and the - // sync helpers cannot do otherwise: the guard is a local. - // `join!` of two `with_vars_async` on one thread can — - // each future holds its guard across an await, so the - // first to finish restores the state saved before the - // second, and the second resumes without its variables. - // Restoring `previous` above has already done that damage - // by the time we get here; what this turns into a failure - // is the silence. - assert_eq!( - innermost, self.depth, - "environment overlay scopes closed out of order: two \ - overlays overlap on this thread. Wrap the `join!`, \ - do not wrap each arm." - ); - } - } - - fn install(vars: I) -> Restore - where - I: IntoIterator)>, - K: AsRef, - V: AsRef, - { - // Nesting layers onto the enclosing overlay rather than - // replacing it, so an inner `with_var` reads like the - // process-environment version it replaces. - let mut map = OVERLAY.with_borrow(|slot| slot.clone().unwrap_or_default()); - for (name, value) in vars { - match value { - Some(value) => { - map.insert(name.as_ref().to_os_string(), value.as_ref().to_os_string()); - } - None => { - map.remove(name.as_ref()); - } - } - } - Restore::install_map(map) - } - - /// Run `f` with `vars` as the entire environment, as far as - /// [`super::var`] and friends are concerned. - /// - /// A `None` value means "unset", which only matters when nesting: - /// at the outermost level every name not listed is already unset. - pub fn with_vars(vars: I, f: F) -> R - where - I: IntoIterator)>, - K: AsRef, - V: AsRef, - F: FnOnce() -> R, - { - let _restore = install(vars); - f() - } - - /// The empty environment: every name reads as unset. - /// - /// What a case whose whole point is "none of this is configured" - /// wants. Unlike [`with_vars`], this does *not* inherit an - /// enclosing overlay — an empty `with_vars` would be a no-op - /// inside one, which is the opposite of what the name promises. - pub fn with_no_vars(f: F) -> R - where - F: FnOnce() -> R, - { - let _restore = Restore::install_map(HashMap::new()); - f() - } - - /// [`with_no_vars`] for an `async` body. - pub async fn with_no_vars_async(future: Fut) -> Fut::Output - where - Fut: Future, - { - let _restore = Restore::install_map(HashMap::new()); - future.await - } - - /// [`with_vars`] for a single variable. - pub fn with_var(name: K, value: Option, f: F) -> R - where - K: AsRef, - V: AsRef, - F: FnOnce() -> R, - { - with_vars([(name, value)], f) - } - - /// [`with_vars`] for an `async` body. - /// - /// The overlay lives on the thread that polls `future`. The - /// returned future is `!Send`, so `tokio::spawn`ing *it* is a - /// compile error rather than a silent read of the real - /// environment. - /// - /// Work the body hands to another thread is a different matter - /// and is not guarded: `spawn_blocking`, `std::thread::spawn`, - /// and `tokio::spawn` / `JoinSet::spawn` on a `multi_thread` - /// runtime all run where there is no overlay. Every - /// `#[tokio::test]` here is the default `current_thread` flavor, - /// where a spawned task stays on this thread and does see it; - /// adding `flavor = "multi_thread"` to a test that spawns under - /// an overlay would silently start reading the host's - /// environment. Such a test needs the value passed in. - /// - /// # Panics - /// - /// Guards must also nest. Two overlay scopes overlapping on one - /// thread (`join!` of two `with_vars_async`) would see each - /// other's variables and restore in the wrong order, so the - /// second scope to close panics instead. Wrap the `join!`, don't - /// wrap each arm. - pub async fn with_vars_async(vars: I, future: Fut) -> Fut::Output - where - I: IntoIterator)>, - K: AsRef, - V: AsRef, - Fut: Future, - { - let _restore = install(vars); - future.await - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - #[should_panic(expected = "closed out of order")] - fn overlapping_scopes_panic_instead_of_answering_wrongly() { - // What `join!`-ing two `with_vars_async` arms does, with - // the awaits taken out: the outer guard drops first. - let outer = install([("MERGIFY_TEST_A", Some("outer"))]); - let inner = install([("MERGIFY_TEST_B", Some("inner"))]); - drop(outer); - drop(inner); - } - - #[test] - fn nesting_in_order_does_not_panic() { - let outer = install([("MERGIFY_TEST_A", Some("outer"))]); - let inner = install([("MERGIFY_TEST_B", Some("inner"))]); - drop(inner); - drop(outer); - assert_eq!(super::super::var("MERGIFY_TEST_A"), None); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn returns_some_for_non_empty_value() { - let got = testing::with_var("MERGIFY_TEST_HELPER_X", Some("hello"), || { - var_non_empty("MERGIFY_TEST_HELPER_X") - }); - assert_eq!(got.as_deref(), Some("hello")); - } - - #[test] - fn returns_none_for_empty_value() { - // The whole point of this helper: an empty env var is - // treated as if it were not set. Regression-prone enough - // that we pin it explicitly. - let got = testing::with_var("MERGIFY_TEST_HELPER_Y", Some(""), || { - var_non_empty("MERGIFY_TEST_HELPER_Y") - }); - assert_eq!(got, None); - } - - #[test] - fn returns_none_when_unset() { - let got = testing::with_var("MERGIFY_TEST_HELPER_Z", None::<&str>, || { - var_non_empty("MERGIFY_TEST_HELPER_Z") - }); - assert_eq!(got, None); - } - - #[test] - fn var_os_non_empty_treats_empty_as_unset() { - // Same empty-as-unset rule as `var_non_empty`, on the path - // that keeps the value an `OsString`. - testing::with_vars( - [("MERGIFY_TEST_A", Some("x")), ("MERGIFY_TEST_B", Some(""))], - || { - assert_eq!(var_os_non_empty("MERGIFY_TEST_A"), Some("x".into())); - assert_eq!(var_os_non_empty("MERGIFY_TEST_B"), None); - assert_eq!(var_os_non_empty("MERGIFY_TEST_C"), None); - }, - ); - } - - #[test] - fn with_no_vars_is_the_empty_environment() { - assert_eq!(testing::with_no_vars(|| var("PATH")), None); - } - - #[test] - fn with_no_vars_does_not_inherit_an_enclosing_overlay() { - // The trap this exists to avoid: `with_vars` layers onto the - // enclosing overlay, so an *empty* `with_vars` nested inside - // one would keep everything and scrub nothing. - testing::with_var("MERGIFY_TEST_A", Some("outer"), || { - testing::with_no_vars(|| assert_eq!(var("MERGIFY_TEST_A"), None)); - assert_eq!(var("MERGIFY_TEST_A").as_deref(), Some("outer")); - }); - } - - #[test] - fn overlay_hides_the_process_environment() { - // The property the old `temp_env` scrub lists existed to - // fake: a variable the test did not list is unset, whatever - // the host exports. `PATH` is the likeliest name to be - // exported, but nothing guarantees it — a process started - // with a cleared environment has none — so read the host's - // value rather than requiring one. - let host_path = var("PATH"); - let got = testing::with_var("MERGIFY_TEST_HELPER_X", Some("hello"), || var("PATH")); - assert_eq!(got, None, "an installed overlay is the whole environment"); - assert_eq!( - var("PATH"), - host_path, - "outside an overlay the real environment is visible again" - ); - } - - #[test] - fn nested_overlays_layer_and_unwind() { - testing::with_vars( - [ - ("MERGIFY_TEST_A", Some("outer")), - ("MERGIFY_TEST_B", Some("b")), - ], - || { - testing::with_vars( - [("MERGIFY_TEST_A", Some("inner")), ("MERGIFY_TEST_B", None)], - || { - assert_eq!(var("MERGIFY_TEST_A").as_deref(), Some("inner")); - assert_eq!(var("MERGIFY_TEST_B"), None); - }, - ); - assert_eq!(var("MERGIFY_TEST_A").as_deref(), Some("outer")); - assert_eq!(var("MERGIFY_TEST_B").as_deref(), Some("b")); - }, - ); - assert_eq!(var("MERGIFY_TEST_A"), None); - } - - #[test] - fn overlay_is_restored_after_a_panic() { - let panicked = std::panic::catch_unwind(|| { - testing::with_var("MERGIFY_TEST_A", Some("x"), || panic!("boom")); - }); - assert!(panicked.is_err()); - // The overlay's own variable, not a host one: `PATH` reads as - // unset both under a leaked overlay and on a host that does - // not export it, so it cannot tell those two apart. - assert_eq!( - var("MERGIFY_TEST_A"), - None, - "the overlay outlived the panic that unwound through it" - ); - } - - #[tokio::test] - async fn overlay_spans_await_points() { - let got = testing::with_vars_async([("MERGIFY_TEST_A", Some("x"))], async { - tokio::task::yield_now().await; - var("MERGIFY_TEST_A") - }) - .await; - assert_eq!(got.as_deref(), Some("x")); - } -} diff --git a/crates/mergify-core/src/error.rs b/crates/mergify-core/src/error.rs deleted file mode 100644 index 83956cc8..00000000 --- a/crates/mergify-core/src/error.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Top-level CLI error type. -//! -//! Commands produce typed errors that map deterministically to an -//! [`ExitCode`]. The binary's `main()` converts a `CliError` into -//! the appropriate `ExitCode`, writes a human-readable message to -//! stderr, and walks the [`std::error::Error::source`] chain so any -//! preserved cause prints as a `caused by:` line before exiting. -//! -//! This enum grows as new error sources are added — add a variant -//! per error category, never a generic `String` catch-all for new -//! kinds of failure that have their own exit code. -//! -//! Prefer preserving a cause over flattening it into a string: -//! - a self-describing typed error → [`CliError::Source`] (or `?` it -//! through the generated `From`), which keeps it transparently; -//! - "doing X failed because of Y" → [`CliError::wrap`], which shows -//! the context as the headline and Y as a `caused by:` line. -//! -//! Reach for `CliError::Generic(e.to_string())` only when there is -//! genuinely no typed cause worth keeping. - -use std::io; - -use crate::exit_code::ExitCode; - -#[derive(thiserror::Error, Debug)] -pub enum CliError { - /// Configuration or credentials missing, unparseable, or failing - /// validation — the config file the user pointed at, and equally - /// the credential a Mergify command could not find anywhere in - /// its resolution chain. Maps to - /// [`ExitCode::ConfigurationError`]. - #[error("{0}")] - Configuration(String), - - /// CLI invariant violated (e.g. branch targets itself, ambiguous - /// commit reference, command run outside a merge queue context). - /// Maps to [`ExitCode::InvalidState`]. - #[error("{0}")] - InvalidState(String), - - /// Stack, branch, or commit not found. Maps to - /// [`ExitCode::StackNotFound`]. - #[error("{0}")] - StackNotFound(String), - - /// Rebase or merge conflict. Maps to [`ExitCode::Conflict`]. - #[error("{0}")] - Conflict(String), - - /// GitHub API failure (HTTP error against github.com). Maps to - /// [`ExitCode::GitHubApiError`]. - #[error("{0}")] - GitHubApi(String), - - /// Mergify API failure (HTTP error against the Mergify - /// service). Maps to [`ExitCode::MergifyApiError`]. - #[error("{0}")] - MergifyApi(String), - - /// Unclassified runtime failure (I/O error, bug, third-party - /// panic captured and rethrown). Maps to - /// [`ExitCode::GenericError`]. - #[error("{0}")] - Generic(String), - - /// `std::io` error surfaced verbatim. Its `Display` is the whole - /// message, so it is intentionally not exposed as a chainable - /// source (that would print the same text twice). Maps to - /// [`ExitCode::GenericError`]. - #[error("{0}")] - Io(io::Error), - - /// A typed lower-level error preserved verbatim and transparently - /// (same `Display`, same source chain), so it survives as a - /// downcastable cause instead of being flattened to a string. - /// Maps to [`ExitCode::GenericError`]. - #[error(transparent)] - Source(#[from] Box), - - /// A contextual headline plus a preserved underlying cause. The - /// `context` is the message; `source` prints as a `caused by:` - /// line. Build with [`CliError::wrap`]. Maps to - /// [`ExitCode::GenericError`]. - #[error("{context}")] - Wrapped { - context: String, - #[source] - source: Box, - }, -} - -impl CliError { - /// Wrap a lower-level error with a contextual message, keeping - /// the original as a `caused by:` source for the printed chain. - pub fn wrap( - context: impl Into, - source: impl Into>, - ) -> Self { - Self::Wrapped { - context: context.into(), - source: source.into(), - } - } - - /// The exit code the binary should return for this error. - #[must_use] - pub const fn exit_code(&self) -> ExitCode { - match self { - Self::Configuration(_) => ExitCode::ConfigurationError, - Self::InvalidState(_) => ExitCode::InvalidState, - Self::StackNotFound(_) => ExitCode::StackNotFound, - Self::Conflict(_) => ExitCode::Conflict, - Self::GitHubApi(_) => ExitCode::GitHubApiError, - Self::MergifyApi(_) => ExitCode::MergifyApiError, - Self::Generic(_) | Self::Io(_) | Self::Source(_) | Self::Wrapped { .. } => { - ExitCode::GenericError - } - } - } -} - -impl From for CliError { - fn from(err: io::Error) -> Self { - Self::Io(err) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn exit_code_mapping_is_total_and_stable() { - assert_eq!( - CliError::Configuration("x".into()).exit_code(), - ExitCode::ConfigurationError, - ); - assert_eq!( - CliError::InvalidState("x".into()).exit_code(), - ExitCode::InvalidState, - ); - assert_eq!( - CliError::StackNotFound("x".into()).exit_code(), - ExitCode::StackNotFound, - ); - assert_eq!( - CliError::Conflict("x".into()).exit_code(), - ExitCode::Conflict - ); - assert_eq!( - CliError::GitHubApi("x".into()).exit_code(), - ExitCode::GitHubApiError, - ); - assert_eq!( - CliError::MergifyApi("x".into()).exit_code(), - ExitCode::MergifyApiError, - ); - assert_eq!( - CliError::Generic("x".into()).exit_code(), - ExitCode::GenericError - ); - let io_err = io::Error::other("boom"); - assert_eq!(CliError::from(io_err).exit_code(), ExitCode::GenericError); - let boxed: Box = "boom".into(); - assert_eq!(CliError::from(boxed).exit_code(), ExitCode::GenericError); - assert_eq!( - CliError::wrap("x", io::Error::other("y")).exit_code(), - ExitCode::GenericError, - ); - } - - #[test] - fn io_display_is_not_also_a_source() { - // `Io`'s Display is the whole message; exposing it as a - // source too would double-print it in the `caused by:` chain. - let err = CliError::from(io::Error::other("disk gone")); - assert_eq!(err.to_string(), "disk gone"); - assert!(std::error::Error::source(&err).is_none()); - } - - #[test] - fn wrapped_keeps_context_as_headline_and_cause_as_source() { - let err = CliError::wrap("write config", io::Error::other("disk gone")); - assert_eq!(err.to_string(), "write config"); - let cause = std::error::Error::source(&err).expect("cause preserved"); - assert_eq!(cause.to_string(), "disk gone"); - } - - #[test] - fn source_is_transparent_for_a_self_describing_leaf() { - // A transparent `Source` shows the inner Display and delegates - // the chain to the inner error (a leaf has no further cause). - let err: CliError = CliError::Source(Box::new(io::Error::other("boom"))); - assert_eq!(err.to_string(), "boom"); - assert!(std::error::Error::source(&err).is_none()); - } -} diff --git a/crates/mergify-core/src/exit_code.rs b/crates/mergify-core/src/exit_code.rs deleted file mode 100644 index b71f62f6..00000000 --- a/crates/mergify-core/src/exit_code.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Typed exit codes for the mergify CLI. -//! -//! Mirrors `mergify_cli.exit_codes.ExitCode` in the Python -//! implementation. The contract — which (command, failure mode) -//! maps to which exit code — is enforced by the compat-test -//! harness. Changing a variant's numeric value is a breaking change -//! for downstream scripts. -//! -//! The enum, its `ALL` list, and the `name`/`description` lookups are -//! all generated from a single variant list by the `exit_codes!` macro, -//! so a new variant can't drift out of any of them — adding one line -//! keeps all four in lockstep. - -use std::process::ExitCode as ProcessExitCode; - -/// Define [`ExitCode`] and everything derived from its variants from one -/// list. Each entry is `Name = code => "description"`; the description -/// doubles as the variant's rustdoc and its runtime [`ExitCode::description`]. -macro_rules! exit_codes { - ( $( $variant:ident = $code:literal => $desc:literal ),+ $(,)? ) => { - /// Structured exit codes. Code 2 is reserved for Click's built-in - /// usage errors in the Python implementation (clap's in Rust) and - /// is therefore not a variant here — it can only be produced by - /// the CLI argument parser. - #[derive(Copy, Clone, Debug, Eq, PartialEq)] - #[repr(u8)] - pub enum ExitCode { - $( #[doc = $desc] $variant = $code ),+ - } - - impl ExitCode { - /// Every variant, in declaration (numeric) order. The single - /// source of truth for enumerating exit codes — the published - /// CLI schema reads this. - pub const ALL: &'static [Self] = &[ $( Self::$variant ),+ ]; - - /// Stable identifier — the enum variant name. - #[must_use] - pub const fn name(self) -> &'static str { - match self { - $( Self::$variant => stringify!($variant), )+ - } - } - - /// One-line meaning for the published reference. Mirrors the - /// `CliError` variant that produces each code. - #[must_use] - pub const fn description(self) -> &'static str { - match self { - $( Self::$variant => $desc, )+ - } - } - } - }; -} - -exit_codes! { - Success = 0 => "Command completed successfully.", - GenericError = 1 => "Unclassified runtime failure (I/O error, bug, or captured panic).", - StackNotFound = 3 => "Stack, branch, or commit not found.", - Conflict = 4 => "Rebase or merge conflict.", - GitHubApiError = 5 => "GitHub API request failed.", - MergifyApiError = 6 => "Mergify API request failed.", - InvalidState = 7 => "CLI invariant violated (e.g. command run outside a valid context).", - ConfigurationError = 8 => "Configuration or credentials missing, unparseable, or failing validation.", -} - -impl ExitCode { - /// Raw u8 value suitable for `std::process::exit` or - /// `std::process::ExitCode::from`. - #[must_use] - pub const fn as_u8(self) -> u8 { - self as u8 - } -} - -impl From for u8 { - fn from(code: ExitCode) -> Self { - code.as_u8() - } -} - -impl From for ProcessExitCode { - fn from(code: ExitCode) -> Self { - Self::from(code.as_u8()) - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use super::*; - - #[test] - fn values_match_python_contract() { - // These numeric values are the contract. Changing any of - // them is a breaking change for downstream scripts. - assert_eq!(ExitCode::Success.as_u8(), 0); - assert_eq!(ExitCode::GenericError.as_u8(), 1); - assert_eq!(ExitCode::StackNotFound.as_u8(), 3); - assert_eq!(ExitCode::Conflict.as_u8(), 4); - assert_eq!(ExitCode::GitHubApiError.as_u8(), 5); - assert_eq!(ExitCode::MergifyApiError.as_u8(), 6); - assert_eq!(ExitCode::InvalidState.as_u8(), 7); - assert_eq!(ExitCode::ConfigurationError.as_u8(), 8); - } - - #[test] - fn two_is_not_used() { - // Code 2 is reserved for Click/clap CLI argument errors. - // No variant may shadow it. - for code in ExitCode::ALL { - assert_ne!(code.as_u8(), 2, "{code:?} must not use code 2"); - } - } - - #[test] - fn all_is_complete_and_ordered() { - // The macro generates `ALL` from the same list as the enum, so it - // can't omit a variant; pin the codes against the contract and - // require strictly ascending order so a renumbering still trips. - let codes: Vec = ExitCode::ALL.iter().map(|c| c.as_u8()).collect(); - assert_eq!(codes, [0, 1, 3, 4, 5, 6, 7, 8]); - assert!( - codes.windows(2).all(|w| w[0] < w[1]), - "ALL must be strictly ascending", - ); - } - - #[test] - fn names_and_descriptions_are_present_and_unique() { - let names: BTreeSet<&str> = ExitCode::ALL.iter().map(|c| c.name()).collect(); - assert_eq!(names.len(), ExitCode::ALL.len(), "names must be unique"); - for code in ExitCode::ALL { - assert!(!code.name().is_empty(), "{code:?} name is empty"); - assert!( - !code.description().is_empty(), - "{code:?} description is empty", - ); - } - } - - #[test] - fn converts_to_u8() { - let code: u8 = ExitCode::ConfigurationError.into(); - assert_eq!(code, 8); - } - - #[test] - fn converts_to_process_exit_code() { - // ProcessExitCode is opaque, so we can't assert its numeric - // value directly, but we verify the conversion at least - // type-checks and does not panic. - let _: ProcessExitCode = ExitCode::Success.into(); - } -} diff --git a/crates/mergify-core/src/http.rs b/crates/mergify-core/src/http.rs deleted file mode 100644 index 06fbe455..00000000 --- a/crates/mergify-core/src/http.rs +++ /dev/null @@ -1,2208 +0,0 @@ -//! HTTP client wrapper used by all ported commands. -//! -//! Wraps [`reqwest::Client`] with: -//! -//! - Bearer-token auth (injected if the token is non-empty). -//! - Tenacity-style retry on 5xx and transient network errors -//! (3 attempts, exponential backoff: 1s, 2s). -//! - Typed error mapping to [`CliError::GitHubApi`] or -//! [`CliError::MergifyApi`] depending on the configured -//! [`ApiFlavor`]. -//! - Per-request timeout (30s default). -//! -//! Command crates must never import [`reqwest`] directly — they go -//! through [`Client::get`], [`Client::post`], or -//! [`Client::post_no_response`] (for endpoints that return an empty -//! body on success). -//! -//! [`Client::post_form`] is the odd one out: it exists for -//! endpoints whose *error* body is part of the protocol rather -//! than a diagnostic — the OAuth device grant answers `400 -//! {"error": "authorization_pending"}` on every poll before its -//! owner approves, and a client that treated that as a failure -//! could never complete the flow. - -use std::time::Duration; - -use reqwest::StatusCode; -use serde::Serialize; -use serde::de::DeserializeOwned; -use url::Url; - -use crate::error::CliError; - -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); -/// Per-connection TCP connect timeout, independent of the overall -/// request budget — a black-holed host shouldn't burn the full 30s -/// before the first byte. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -/// Upper bound on a rate-limit-dictated wait. GitHub's reset window -/// can be an hour out; the client honours `Retry-After` / reset only -/// up to this cap, then fails fast rather than blocking the CLI on an -/// interactive command. -const MAX_RATE_LIMIT_WAIT: Duration = Duration::from_secs(30); -const DEFAULT_MAX_ATTEMPTS: u32 = 3; -const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1); -/// User-Agent header sent on every request. GitHub's REST API -/// rejects requests without one (`403 Request forbidden by -/// administrative rules`), so this is non-negotiable. Cargo's -/// package version is fine here — calver vs semver doesn't -/// matter to GitHub, only that the header is present and -/// identifies the client. -const USER_AGENT: &str = concat!("mergify-cli/", env!("CARGO_PKG_VERSION")); -/// Cap on how many bytes of an error response body we surface in -/// `CliError`. A misbehaving server can return arbitrarily large -/// payloads; truncating keeps the CLI output sane and bounds memory -/// use. -const MAX_ERROR_BODY_BYTES: usize = 4 * 1024; - -/// Which backend the client talks to. Determines whether HTTP -/// failures are mapped to [`CliError::GitHubApi`] or -/// [`CliError::MergifyApi`]. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum ApiFlavor { - GitHub, - Mergify, -} - -/// Outcome of [`Client::delete_if_exists`]. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum DeleteOutcome { - /// 2xx: the resource was deleted. - Deleted, - /// 404: the resource didn't exist (or was already gone). - NotFound, -} - -/// Outcome of a request whose non-2xx bodies are part of the -/// endpoint's protocol — see [`Client::post_form`]. -/// -/// Only *terminal* rejections reach [`Self::Error`]: 5xx and -/// rate-limit responses never do, however many retries they have -/// used, and a body that does not deserialize as `E` — nor as an -/// empty `E`, for a type whose every field is optional — is a plain -/// [`CliError`] rather than a protocol answer. -#[derive(Debug, Eq, PartialEq)] -pub enum ApiOutcome { - Ok(T), - Error { status: u16, body: E }, -} - -/// One page of a cursor-paginated Mergify list endpoint. -/// -/// `next_cursor` is the opaque cursor of the following page, -/// extracted from the response's RFC 5988 `Link` header -/// (`rel="next"`); `None` on the last page. The caller re-issues its -/// own query with `("cursor", …)` appended — only the cursor is -/// taken from the header, never the rest of the echoed URL, so a -/// caller's query cannot be silently rewritten by the server. -pub struct Page { - pub body: T, - pub next_cursor: Option, -} - -/// What the retry driver does with a **terminal** non-2xx response — -/// one it has already decided not to retry. -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -enum OnTerminalError { - /// Render it into a [`CliError`]. The default. - Fail, - /// The given status short-circuits to `Ok(None)`; everything - /// else fails. - StatusIsNone(u16), - /// Hand the response back unread so the caller can decode a - /// protocol-defined error body. - ReturnResponse, -} - -/// Caller hook to remap a terminal non-2xx HTTP status to a domain -/// error before the default flavor mapping. Receives the status as a -/// `u16` (command crates never import `reqwest`) and the rendered -/// error message; returning `Some` overrides the error. `config -/// simulate` uses it to map the simulator's 422 to a config error. -type ErrorClassifier<'a> = &'a (dyn Fn(u16, &str) -> Option + Send + Sync); - -/// Retry policy for transient failures. Only 5xx responses and -/// connect/timeout errors are retried; 4xx responses are never -/// retried — those are caller errors and retrying would hide bugs. -#[derive(Copy, Clone, Debug)] -pub struct RetryPolicy { - pub max_attempts: u32, - pub initial_backoff: Duration, -} - -impl Default for RetryPolicy { - fn default() -> Self { - Self { - max_attempts: DEFAULT_MAX_ATTEMPTS, - initial_backoff: DEFAULT_INITIAL_BACKOFF, - } - } -} - -pub struct Client { - inner: reqwest::Client, - base_url: Url, - flavor: ApiFlavor, - token: Option, - retry: RetryPolicy, -} - -impl Client { - /// Build a client with the default retry policy. - pub fn new( - base_url: Url, - token: impl Into, - flavor: ApiFlavor, - ) -> Result { - Self::with_retry_policy(base_url, token, flavor, RetryPolicy::default()) - } - - /// Build a client with a custom retry policy. Used by tests to - /// skip the real-wall-clock backoff delay. - /// - /// # Errors - /// - /// Returns [`CliError::Generic`] when `retry.max_attempts` is - /// `0` — a zero-attempt policy would cause every request to - /// short-circuit with a misleading "failed without response" - /// message. - pub fn with_retry_policy( - base_url: Url, - token: impl Into, - flavor: ApiFlavor, - retry: RetryPolicy, - ) -> Result { - if retry.max_attempts == 0 { - return Err(CliError::Generic( - "RetryPolicy::max_attempts must be at least 1".to_string(), - )); - } - let token_str = token.into(); - let token_opt = (!token_str.is_empty()).then_some(token_str); - let inner = reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .connect_timeout(CONNECT_TIMEOUT) - .user_agent(USER_AGENT) - .build() - .map_err(|e| CliError::Generic(format!("build HTTP client: {e}")))?; - Ok(Self { - inner, - base_url, - flavor, - token: token_opt, - retry, - }) - } - - /// GET `path` and deserialize the JSON body as `T`. - pub async fn get(&self, path: &str) -> Result { - let url = self.join(path)?; - let resp = self.execute_request(self.inner.get(url)).await?; - self.decode_json(resp).await - } - - /// GET `path`, returning `None` on 404. Other 4xx/5xx responses - /// surface as the normal `CliError` API failure. Mirrors - /// [`Self::delete_if_exists`] but for read-only endpoints where - /// "not found" is a meaningful caller branch (e.g. `queue show` - /// must distinguish "PR not in queue" from a genuine API - /// failure). - pub async fn get_if_exists( - &self, - path: &str, - ) -> Result, CliError> { - let url = self.join(path)?; - match self.execute_request_optional(self.inner.get(url)).await? { - Some(resp) => self.decode_json(resp).await.map(Some), - None => Ok(None), - } - } - - /// GET `path` with query-string pairs appended in caller order. - /// - /// Repeating the same key is supported (each entry produces its - /// own `key=value`), and values are percent-encoded so callers can - /// pass arbitrary strings (`*`, `&`, `?`, spaces, unicode). - /// An empty `query` slice produces no `?`. - pub async fn get_with_query( - &self, - path: &str, - query: &[(&str, &str)], - ) -> Result { - let mut url = self.join(path)?; - if !query.is_empty() { - url.query_pairs_mut().extend_pairs(query.iter().copied()); - } - let resp = self.execute_request(self.inner.get(url)).await?; - self.decode_json(resp).await - } - - /// GET one page of a cursor-paginated endpoint: like - /// [`Self::get_with_query`], but also return the next page's - /// cursor from the response's `Link` header (see [`Page`]). - pub async fn get_page( - &self, - path: &str, - query: &[(&str, &str)], - ) -> Result, CliError> { - // `OnTerminalError::Fail` never returns `None`; - // `Option::expect` documents that invariant. - Ok(self - .fetch_page(path, query, OnTerminalError::Fail) - .await? - .expect("fetch_page returned None despite OnTerminalError::Fail")) - } - - /// [`Self::get_page`], but return `None` when the server answers - /// `status` — for an endpoint where that status is a routine - /// caller branch (e.g. 402: the feature is not in the plan). - pub async fn get_page_unless( - &self, - path: &str, - query: &[(&str, &str)], - status: u16, - ) -> Result>, CliError> { - self.fetch_page(path, query, OnTerminalError::StatusIsNone(status)) - .await - } - - async fn fetch_page( - &self, - path: &str, - query: &[(&str, &str)], - terminal: OnTerminalError, - ) -> Result>, CliError> { - let mut url = self.join(path)?; - if !query.is_empty() { - url.query_pairs_mut().extend_pairs(query.iter().copied()); - } - let Some(resp) = self - .execute_with_retry(self.inner.get(url), terminal, None) - .await? - else { - return Ok(None); - }; - let next_cursor = resp - .headers() - .get(reqwest::header::LINK) - .and_then(|value| value.to_str().ok()) - .and_then(next_cursor_from_link); - let body = self.decode_json(resp).await?; - Ok(Some(Page { body, next_cursor })) - } - - /// GET `path`, decoding the JSON body as `T` on success and as - /// `E` on a terminal rejection — the [`ApiOutcome`] counterpart - /// of [`Self::post_form`], for a caller that has to branch on - /// *which* rejection it got. - /// - /// `auth status` is the first caller: a refused credential (403) - /// and a deployment too old to serve the route (404) are - /// different sentences to the user, and both are different from - /// a network failure. Reporting any of the three as another - /// would be a lie. - pub async fn get_outcome( - &self, - path: &str, - ) -> Result, CliError> { - let url = self.join(path)?; - let resp = self - // `OnTerminalError::ReturnResponse` never returns `None`; - // `Option::expect` documents that invariant. - .execute_with_retry(self.inner.get(url), OnTerminalError::ReturnResponse, None) - .await? - .expect("execute_with_retry returned None despite OnTerminalError::ReturnResponse"); - self.decode_outcome(resp).await - } - - /// POST `body` as JSON to `path` and deserialize the JSON - /// response as `T`. - pub async fn post( - &self, - path: &str, - body: &B, - ) -> Result { - let url = self.join(path)?; - let resp = self - .execute_request(self.inner.post(url).json(body)) - .await?; - self.decode_json(resp).await - } - - /// POST `body` as JSON to `path` and deserialize the JSON response - /// as `T`, like [`Self::post`], but consult `classify` on a - /// terminal non-2xx response before the default flavor mapping: - /// returning `Some(err)` overrides the error. `config simulate` - /// uses this to surface the simulator's 422 — an unprocessable - /// local config — as a [`CliError::Configuration`] (exit 8) - /// rather than a Mergify API failure (exit 6). The classifier - /// receives the HTTP status as a `u16` (command crates never - /// import `reqwest`) and the rendered error message. - pub async fn post_classifying( - &self, - path: &str, - body: &B, - classify: impl Fn(u16, &str) -> Option + Send + Sync, - ) -> Result { - let url = self.join(path)?; - let resp = self - // `OnTerminalError::Fail` means the driver never returns - // `None`; `Option::expect` documents that invariant. - .execute_with_retry( - self.inner.post(url).json(body), - OnTerminalError::Fail, - Some(&classify), - ) - .await? - .expect("execute_with_retry returned None despite OnTerminalError::Fail"); - self.decode_json(resp).await - } - - /// POST `body` as JSON to `path` and discard the response body. - /// Use when the endpoint returns an empty body (or any body the - /// caller does not care about) on success — `post::` would - /// fail to deserialize an empty response. - pub async fn post_no_response( - &self, - path: &str, - body: &B, - ) -> Result<(), CliError> { - let url = self.join(path)?; - self.execute_request(self.inner.post(url).json(body)) - .await - .map(drop) - } - - /// POST `form` as `application/x-www-form-urlencoded`, decoding - /// the JSON body as `T` on success and as `E` on a terminal - /// rejection. - /// - /// For endpoints whose error body is part of the protocol rather - /// than a diagnostic. The OAuth device grant is the first caller: - /// it answers `400 {"error": "authorization_pending"}` on every - /// poll until its owner approves, so a client that could only see - /// "the request failed" could never complete the flow. - /// - /// Retries are unchanged — 5xx and rate limits are retried before - /// anything is handed back — and a rejection whose body does not - /// deserialize as `E` is a plain [`CliError`], so a proxy's HTML - /// 502 page stays as diagnosable as it is on every other verb. - pub async fn post_form( - &self, - path: &str, - form: &[(&str, &str)], - ) -> Result, CliError> { - let url = self.join(path)?; - let resp = self - // `OnTerminalError::ReturnResponse` never returns `None`; - // `Option::expect` documents that invariant. - .execute_with_retry( - self.inner.post(url).form(form), - OnTerminalError::ReturnResponse, - None, - ) - .await? - .expect("execute_with_retry returned None despite OnTerminalError::ReturnResponse"); - self.decode_outcome(resp).await - } - - /// Split a response handed back by - /// [`OnTerminalError::ReturnResponse`] into the two halves of an - /// [`ApiOutcome`]. A rejection whose body does not deserialize as - /// `E` is not the protocol speaking, so it stays a [`CliError`] - /// carrying the same message every other verb would have printed. - async fn decode_outcome( - &self, - resp: reqwest::Response, - ) -> Result, CliError> { - let status = resp.status(); - if status.is_success() { - return Ok(ApiOutcome::Ok(self.decode_json(resp).await?)); - } - let rejection = error_response(status, resp).await; - // A rejection body that is not the protocol's error type is - // still the protocol speaking when the caller's type can - // represent an *empty* one. Any gateway in front of a - // self-hosted install answers 401 or 404 with its own HTML - // page or no body at all, and there the status is the whole - // answer — dropping it would send a caller that maps 404 to - // "this deployment is too old" a bare transport error - // instead. When `E` has a required field the body is - // genuinely unusable, and this stays the `CliError` every - // other verb would have printed. - let body = serde_json::from_slice::(&rejection.body) - .or_else(|_| serde_json::from_str::("{}")); - match body { - Ok(body) => Ok(ApiOutcome::Error { - status: status.as_u16(), - body, - }), - Err(_) => Err(self.api_error(rejection.message)), - } - } - - /// POST `form` as `application/x-www-form-urlencoded` and discard - /// the response body. The form counterpart of - /// [`Self::post_no_response`]. - pub async fn post_form_no_response( - &self, - path: &str, - form: &[(&str, &str)], - ) -> Result<(), CliError> { - let url = self.join(path)?; - self.execute_request(self.inner.post(url).form(form)) - .await - .map(drop) - } - - /// POST to `path` with **no** request body, treating 404 as - /// success and discarding the response body. - /// - /// For "make sure this is off" endpoints that take no body and - /// answer with an empty 2xx — GitHub's `POST - /// /repos/{o}/{r}/stacks/{n}/unstack` is the first caller. A 404 - /// means the resource is already gone, which is exactly the - /// postcondition the caller wanted, so it is not an error (same - /// reasoning as [`Self::delete_if_exists`]). - /// - /// Distinct from [`Self::post_no_response`], which serializes a - /// JSON body and treats 404 as a failure: an endpoint documented - /// as taking no body should be sent none, not a JSON `null`. - pub async fn post_empty_if_exists(&self, path: &str) -> Result<(), CliError> { - let url = self.join(path)?; - self.execute_with_retry( - self.inner.post(url), - OnTerminalError::StatusIsNone(StatusCode::NOT_FOUND.as_u16()), - None, - ) - .await - .map(drop) - } - - /// PUT `body` as JSON to `path` and deserialize the JSON - /// response as `T`. - pub async fn put( - &self, - path: &str, - body: &B, - ) -> Result { - let url = self.join(path)?; - let resp = self.execute_request(self.inner.put(url).json(body)).await?; - self.decode_json(resp).await - } - - /// PUT `body` as JSON to `path`, discard the response body, and - /// report whether the endpoint exists: `Ok(false)` on 404. - /// - /// For probing a route a Mergify deployment older than the CLI - /// does not serve yet, so the caller can fall back to one it does - /// — `ci scopes-send` is the first caller. 404 is terminal (never - /// retried), so the probe costs exactly one request. - /// - /// Distinct from [`Self::put`], which treats 404 as a failure like - /// any other 4xx, and from [`Self::post_no_response`], which does - /// the same for POST. - pub async fn put_no_response_if_exists( - &self, - path: &str, - body: &B, - ) -> Result { - let url = self.join(path)?; - Ok(self - .execute_request_optional(self.inner.put(url).json(body)) - .await? - .is_some()) - } - - /// PATCH `body` as JSON to `path` and deserialize the JSON - /// response as `T`. Mirrors [`Self::put`] but for endpoints that - /// use the more permissive PATCH semantics (partial update) — - /// `freeze update` is the first caller. - pub async fn patch( - &self, - path: &str, - body: &B, - ) -> Result { - let url = self.join(path)?; - let resp = self - .execute_request(self.inner.patch(url).json(body)) - .await?; - self.decode_json(resp).await - } - - /// DELETE `path`, returning whether the resource existed. - /// - /// Returns `Ok(DeleteOutcome::Deleted)` on 2xx responses and - /// `Ok(DeleteOutcome::NotFound)` on 404 — useful for idempotent - /// "turn this thing off if it's on" operations where 404 means - /// "nothing to do". 4xx-other and 5xx map to the normal API - /// errors. - pub async fn delete_if_exists(&self, path: &str) -> Result { - let url = self.join(path)?; - self.execute_status(self.inner.delete(url)).await - } - - /// Resolve `path` against `base_url`. - /// - /// Every call site writes `path` with a leading `/`, but it is - /// meant as a *relative* reference: GitHub Enterprise's API base - /// carries an `/api/v3` prefix, and RFC 3986 resolves a reference - /// starting with `/` by replacing the base's whole path instead of - /// extending it. Resolved literally, `https://ghe/api/v3` + `/user` - /// is `https://ghe/user` — the web UI, which redirects to the login - /// page and surfaces as a JSON decode error against a `/login?…` - /// URL. So the leading `/` comes off and the base gets a trailing - /// one, which makes the join append. - /// - /// The resolution stays inside `Url::join` rather than - /// concatenating strings onto `base_url`'s path: `path` may carry - /// an embedded `?query` (`…/merge-queue/status?branch=main`), and - /// `set_path` percent-encodes the `?` into a literal path byte. - fn join(&self, path: &str) -> Result { - // `Url::join` accepts absolute URLs and protocol-relative - // paths (`//host/...`), which would let a caller-supplied - // `path` swap out `base_url`'s authority and leak the bearer - // token to an arbitrary host. - // - // Both forms are checked before AND after the leading `/` comes - // off, because each strips past the other: `//evil.example/foo` - // is only protocol-relative while the slash is there, and - // `/https://evil.example/foo` is only an absolute URL once it is - // gone. - let relative = path.strip_prefix('/').unwrap_or(path); - if points_at_another_host(path) || points_at_another_host(relative) { - return Err(self.api_error(format!( - "invalid path {path:?}: absolute URLs are not allowed" - ))); - } - - let mut base = self.base_url.clone(); - if !base.path().ends_with('/') { - // `push("")` appends an empty segment — a trailing slash — - // without re-encoding the segments already there, which - // `set_path` on a percent-encoded path would risk. - base.path_segments_mut() - .map_err(|()| { - self.api_error(format!( - "invalid base URL {}: cannot be a base", - self.base_url - )) - })? - .push(""); - } - base.join(relative) - .map_err(|e| self.api_error(format!("invalid path {path:?}: {e}"))) - } - - /// Single retry/auth/error driver behind every public verb. - /// - /// `terminal` says what happens to a non-2xx the driver has - /// decided not to retry — see [`OnTerminalError`]. - /// - /// Success (2xx) always returns `Ok(Some(response))` — the - /// caller decides whether to decode the body, drop it, or - /// map it to a domain type. - /// - /// 5xx is retried with exponential backoff (`self.retry`); - /// transient send errors (timeout / connect) are retried with - /// the same backoff. Other terminal errors and non-5xx 4xx - /// fail immediately. - async fn execute_with_retry( - &self, - builder: reqwest::RequestBuilder, - terminal: OnTerminalError, - classify_error: Option>, - ) -> Result, CliError> { - let mut backoff = self.retry.initial_backoff; - let mut last_message = String::from("HTTP request failed without response"); - - for attempt in 0..self.retry.max_attempts { - let Some(cloned) = builder.try_clone() else { - return Err(self.api_error( - "request body is not cloneable (streaming?) — cannot retry".into(), - )); - }; - let req = match &self.token { - Some(token) => cloned.bearer_auth(token), - None => cloned, - }; - - tracing::debug!( - service = self.service_name(), - attempt = attempt + 1, - max_attempts = self.retry.max_attempts, - "sending HTTP request" - ); - match req.send().await { - Ok(resp) => { - let status = resp.status(); - if status.is_success() { - return Ok(Some(resp)); - } - // Inspect rate-limit headers before the body is - // read. GitHub signals secondary/abuse limits with - // 429, or 403 carrying `Retry-After` / an exhausted - // `X-RateLimit-Remaining`. A bare 403 (auth / - // permission denied) must NOT be retried. - let rate_limit = rate_limit_wait(&resp); - let rate_limited = status == StatusCode::TOO_MANY_REQUESTS - || (status == StatusCode::FORBIDDEN && rate_limit.is_some()); - let retryable = (status.is_server_error() || rate_limited) - && attempt + 1 < self.retry.max_attempts; - let protocol_answer = !retryable && !status.is_server_error() && !rate_limited; - // After the retry decision: a server failure or a - // throttle is never a routine absence. - if protocol_answer && terminal == OnTerminalError::StatusIsNone(status.as_u16()) - { - return Ok(None); - } - // Terminal, and the caller wants the body: hand the - // response over unread. Reading it here to render a - // message would consume the very bytes the caller - // needs, so this has to come before `error_message` - // and after the retry decision. - // - // Never for a 5xx, even one that has run out of - // retries. A server error is the server failing, - // not the protocol speaking, and its body can - // still parse as the protocol's error type — - // `{"detail": …}` deserializes into anything whose - // fields are optional. Letting it through would - // trade "HTTP 500 …\nurl: …" for whatever the - // caller's error type made of it. - // - // Never for a rate limit either, for the same - // reason and with a sharper edge: an exhausted - // 403-with-`Retry-After` is a gateway throttling - // this client, and a caller that reads a 403 as - // "the credential was refused" would tell the user - // their credential is revoked — `auth status` - // does exactly that. - if protocol_answer && terminal == OnTerminalError::ReturnResponse { - return Ok(Some(resp)); - } - last_message = error_message(status, resp).await; - if retryable { - // A rate-limit response dictates the wait, capped - // so the CLI never blocks on a far-off reset; - // everything else uses exponential backoff. A - // reset beyond the cap fails fast. - let delay = match rate_limit { - Some(wait) if wait > MAX_RATE_LIMIT_WAIT => { - return Err(self.api_error(last_message)); - } - Some(wait) => wait, - None => backoff, - }; - tracing::warn!( - service = self.service_name(), - %status, - ?delay, - "retrying after error response" - ); - tokio::time::sleep(delay).await; - backoff *= 2; - continue; - } - // Terminal non-2xx: let the caller remap specific - // statuses (e.g. simulate's 422 → config error) - // before the default flavor mapping. - if let Some(classify) = classify_error - && let Some(mapped) = classify(status.as_u16(), &last_message) - { - return Err(mapped); - } - return Err(self.api_error(last_message)); - } - Err(e) if is_transient(&e) && attempt + 1 < self.retry.max_attempts => { - tracing::debug!( - service = self.service_name(), - error = %e, - ?backoff, - "retrying after transient network error" - ); - last_message = format!("network error: {e}"); - tokio::time::sleep(backoff).await; - backoff *= 2; - } - Err(e) => { - return Err(self.api_error(self.terminal_send_error_message(&e))); - } - } - } - Err(self.api_error(last_message)) - } - - /// Send a request that must return a response. 404 is treated - /// like any other 4xx (caller error → [`CliError`]). - async fn execute_request( - &self, - builder: reqwest::RequestBuilder, - ) -> Result { - // `OnTerminalError::Fail` means the driver never returns - // `None`; `Option::expect` documents that invariant. - Ok(self - .execute_with_retry(builder, OnTerminalError::Fail, None) - .await? - .expect("execute_with_retry returned None despite OnTerminalError::Fail")) - } - - /// Send a request where 404 is a routine caller branch - /// rather than a server failure. Used by [`Self::get_if_exists`]. - async fn execute_request_optional( - &self, - builder: reqwest::RequestBuilder, - ) -> Result, CliError> { - self.execute_with_retry( - builder, - OnTerminalError::StatusIsNone(StatusCode::NOT_FOUND.as_u16()), - None, - ) - .await - } - - /// Send a request that cares only about the HTTP status. - /// Used by [`Self::delete_if_exists`] — the response body - /// (if any) is discarded. - async fn execute_status( - &self, - builder: reqwest::RequestBuilder, - ) -> Result { - match self - .execute_with_retry( - builder, - OnTerminalError::StatusIsNone(StatusCode::NOT_FOUND.as_u16()), - None, - ) - .await? - { - Some(_) => Ok(DeleteOutcome::Deleted), - None => Ok(DeleteOutcome::NotFound), - } - } - - async fn decode_json( - &self, - resp: reqwest::Response, - ) -> Result { - resp.json::() - .await - .map_err(|e| self.api_error(format!("parse response JSON: {e}"))) - } - - fn api_error(&self, message: String) -> CliError { - match self.flavor { - ApiFlavor::GitHub => CliError::GitHubApi(message), - ApiFlavor::Mergify => CliError::MergifyApi(message), - } - } - - fn service_name(&self) -> &'static str { - match self.flavor { - ApiFlavor::GitHub => "GitHub", - ApiFlavor::Mergify => "Mergify", - } - } - - /// Render a non-retried `reqwest` send error as the message - /// body for `CliError`. Shared between the GET/POST/PUT path - /// (`execute_request`) and the DELETE-style status-only path - /// (`execute_status`) so verbs don't drift on user-facing - /// diagnostics — timeouts and connect failures must read the - /// same regardless of HTTP method. - fn terminal_send_error_message(&self, e: &reqwest::Error) -> String { - let svc = self.service_name(); - let base = if e.is_timeout() { - format!("{svc} did not respond in time. The request was aborted — please retry.") - } else if e.is_connect() { - format!("could not reach {svc}: {e}") - } else { - format!("request failed: {e}") - }; - // Surface the contacted URL so a hung or mis-configured - // endpoint (e.g. a wrong --api-url) is diagnosable — Python - // printed it in the same network-error message. - match e.url() { - Some(url) => format!("{base} (url: {url})"), - None => base, - } - } -} - -/// Whether `reference` would resolve to a host of its own rather than -/// against a base URL — an absolute URL (`https://evil.example/foo`) -/// or a protocol-relative reference (`//evil.example/foo`). -fn points_at_another_host(reference: &str) -> bool { - reference.starts_with("//") || Url::parse(reference).is_ok() -} - -fn is_transient(e: &reqwest::Error) -> bool { - e.is_timeout() || e.is_connect() -} - -/// Extract the next page's `cursor` from an RFC 5988 `Link` header -/// value (`; rel="next", ; rel="last", …`). -/// -/// Only the `cursor` query parameter of the `rel="next"` target is -/// returned — the pagination contract is "same query, new cursor", -/// so the caller keeps building its own request rather than blindly -/// following a server-echoed URL. `None` when there is no `next` -/// link or its URL carries no cursor (both mean "last page"). -fn next_cursor_from_link(header: &str) -> Option { - for part in header.split(',') { - let mut segments = part.split(';'); - let target = segments.next()?.trim(); - let is_next = segments.any(|param| { - let param = param.trim(); - param - .strip_prefix("rel=") - .is_some_and(|rel| rel.trim_matches('"') == "next") - }); - if !is_next { - continue; - } - let target = target.strip_prefix('<')?.strip_suffix('>')?; - let url = Url::parse(target).ok()?; - return url - .query_pairs() - .find(|(key, _)| key == "cursor") - .map(|(_, value)| value.into_owned()); - } - None -} - -/// The wait hinted by a rejection's rate-limit headers, if any. -/// Prefers `Retry-After` (delta-seconds, as GitHub sends); falls back -/// to `X-RateLimit-Reset` (epoch seconds) when `X-RateLimit-Remaining` -/// is `0`. Returns `None` when there is no rate-limit signal — the -/// caller uses that to tell a rate-limited 403 from a permission 403. -fn rate_limit_wait(resp: &reqwest::Response) -> Option { - let headers = resp.headers(); - if let Some(secs) = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.trim().parse::().ok()) - { - return Some(Duration::from_secs(secs)); - } - let exhausted = headers - .get("x-ratelimit-remaining") - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.trim() == "0"); - if exhausted { - let reset = headers - .get("x-ratelimit-reset") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.trim().parse::().ok())?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - return Some(Duration::from_secs(reset.saturating_sub(now))); - } - None -} - -/// A rejection's rendered message plus the (capped) bytes it was -/// rendered from, so [`Client::post_form`] can try the body as a -/// protocol answer without reading the response twice. -struct ErrorResponse { - message: String, - body: Vec, -} - -async fn error_message(status: StatusCode, resp: reqwest::Response) -> String { - error_response(status, resp).await.message -} - -async fn error_response(status: StatusCode, mut resp: reqwest::Response) -> ErrorResponse { - // Capture the URL before the body stream consumes `resp` — a - // failing endpoint (e.g. a mis-resolved --api-url) is surfaced on - // a trailing `url:` line, matching Python's `check_for_status`. - let url = resp.url().clone(); - - // Stream chunks until we've buffered at most `MAX_ERROR_BODY_BYTES`, - // then drop the rest. `Response::text()` would slurp the entire - // body into memory regardless of size. - let mut body: Vec = Vec::new(); - let mut truncated = false; - while let Ok(Some(chunk)) = resp.chunk().await { - if body.len() + chunk.len() > MAX_ERROR_BODY_BYTES { - let remaining = MAX_ERROR_BODY_BYTES - body.len(); - body.extend_from_slice(&chunk[..remaining]); - truncated = true; - break; - } - body.extend_from_slice(&chunk); - } - let mut text = String::from_utf8_lossy(&body).into_owned(); - if truncated { - text.push_str("…[truncated]"); - } - - // Prefer the JSON `detail` field the Mergify API returns so the - // user sees a clean sentence instead of the raw `{"detail": …}` - // envelope. Only when the (untruncated) body parses as an object - // carrying a string `detail`; otherwise fall back to the body text. - let detail = if truncated { - None - } else { - serde_json::from_slice::(&body) - .ok() - .and_then(|v| { - v.get("detail") - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - }) - }; - - let head = match detail { - Some(detail) => format!("HTTP {status}: {detail}"), - None if text.is_empty() => format!("HTTP {status}"), - None => format!("HTTP {status}: {text}"), - }; - ErrorResponse { - message: format!("{head}\nurl: {url}"), - body, - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - use std::sync::atomic::AtomicU32; - use std::sync::atomic::Ordering; - - use serde::Deserialize; - use serde::Serialize; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::Request; - use wiremock::Respond; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; - use wiremock::matchers::body_string; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[derive(Serialize, Deserialize, Debug, PartialEq)] - struct Foo { - bar: u32, - } - - fn fast_client(server: &MockServer, flavor: ApiFlavor) -> Client { - Client::with_retry_policy( - Url::parse(&server.uri()).unwrap(), - "test-token", - flavor, - RetryPolicy { - max_attempts: 3, - initial_backoff: Duration::from_millis(0), - }, - ) - .unwrap() - } - - /// A client with no server behind it, for the URL-shaping tests: - /// `join` never leaves the process, so a base URL is all they need. - fn client_based_at(base: &str) -> Client { - Client::new(Url::parse(base).unwrap(), "test-token", ApiFlavor::GitHub).unwrap() - } - - #[derive(Deserialize, Debug, PartialEq)] - struct OAuthError { - error: String, - } - - /// An error type whose every field is optional, the shape the - /// Mergify API's own `{"detail": …}` rejections have. - #[derive(Deserialize, Debug, PartialEq)] - struct Detail { - #[serde(default)] - detail: Option, - } - - #[tokio::test] - async fn get_outcome_hands_back_the_status_of_a_terminal_rejection() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with( - ResponseTemplate::new(403) - .set_body_json(serde_json::json!({"detail": "not allowed"})), - ) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let outcome: ApiOutcome = - client.get_outcome("/v1/user").await.unwrap(); - match outcome { - ApiOutcome::Error { status, .. } => assert_eq!(status, 403), - ApiOutcome::Ok(body) => panic!("expected a rejection, got {body:?}"), - } - } - - #[tokio::test] - async fn get_outcome_decodes_a_successful_body() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 3 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let outcome: ApiOutcome = - client.get_outcome("/v1/user").await.unwrap(); - assert_eq!(outcome, ApiOutcome::Ok(Foo { bar: 3 })); - } - - #[tokio::test] - async fn post_form_sends_urlencoded_and_decodes_success() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .and(header("Content-Type", "application/x-www-form-urlencoded")) - .and(body_string("grant_type=device&device_code=abc%2Fdef")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 7 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let outcome: ApiOutcome = client - .post_form( - "/oauth/token", - &[("grant_type", "device"), ("device_code", "abc/def")], - ) - .await - .unwrap(); - assert_eq!(outcome, ApiOutcome::Ok(Foo { bar: 7 })); - } - - // The reason this verb exists: a 400 carrying the protocol's own - // error code is an answer, not a failure. - #[tokio::test] - async fn post_form_returns_a_terminal_rejection_as_a_protocol_answer() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .respond_with( - ResponseTemplate::new(400) - .set_body_json(serde_json::json!({"error": "authorization_pending"})), - ) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let outcome: ApiOutcome = - client.post_form("/oauth/token", &[]).await.unwrap(); - assert_eq!( - outcome, - ApiOutcome::Error { - status: 400, - body: OAuthError { - error: "authorization_pending".to_string(), - }, - }, - ); - } - - // A rejection that is not the protocol speaking — a proxy's error - // page, a misrouted request — must stay as diagnosable as it is on - // every other verb. - #[tokio::test] - async fn post_form_falls_back_to_a_cli_error_when_the_body_is_not_the_protocol() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .respond_with(ResponseTemplate::new(400).set_body_string("go away")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_form::("/oauth/token", &[]) - .await - .unwrap_err(); - let message = err.to_string(); - assert!(message.contains("400"), "got {message:?}"); - assert!(message.contains("go away"), "got {message:?}"); - } - - // An exhausted rate limit is not the protocol speaking either. - // The sharp case is a 403 carrying `Retry-After`: handed back as - // a protocol answer, `auth status` reads it as a revoked - // credential and sends the user to mint another one. - #[tokio::test] - async fn post_form_keeps_the_diagnostic_for_an_exhausted_rate_limit() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .respond_with( - ResponseTemplate::new(403) - .append_header("retry-after", "0") - .set_body_json(serde_json::json!({"error": "slow down"})), - ) - .expect(3) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_form::("/oauth/token", &[]) - .await - .unwrap_err(); - let message = err.to_string(); - assert!(message.contains("403"), "got {message:?}"); - assert!(message.contains("url:"), "got {message:?}"); - } - - // A gateway's own 401/404 carries no JSON at all, and the status - // is the entire answer. It has to survive, or every caller that - // branches on one gets a transport error instead. - #[tokio::test] - async fn get_outcome_keeps_the_status_of_a_rejection_with_no_usable_body() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/user")) - .respond_with(ResponseTemplate::new(404).set_body_string("nope")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let outcome: ApiOutcome = client.get_outcome("/v1/user").await.unwrap(); - assert_eq!( - outcome, - ApiOutcome::Error { - status: 404, - body: Detail { detail: None }, - }, - ); - } - - // Handing the response body to the caller must not cost the retry - // policy: a 500 is still retried, and only the last one is handed - // back. - #[tokio::test] - async fn post_form_still_retries_server_errors() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .respond_with(ResponseTemplate::new(500).set_body_string("boom")) - .expect(3) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_form::("/oauth/token", &[]) - .await - .unwrap_err(); - assert!(err.to_string().contains("500"), "got {err}"); - } - - // A 5xx that has run out of retries is the server failing, not - // the protocol speaking — and its body can parse as the - // protocol's error type anyway, since every field there is - // optional. - #[tokio::test] - async fn post_form_keeps_the_full_diagnostic_for_an_exhausted_server_error() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/token")) - .respond_with( - ResponseTemplate::new(500) - .set_body_json(serde_json::json!({"error": "upstream down"})), - ) - .expect(3) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_form::("/oauth/token", &[]) - .await - .unwrap_err(); - let message = err.to_string(); - assert!(message.contains("500"), "got {message:?}"); - assert!(message.contains("url:"), "got {message:?}"); - } - - #[tokio::test] - async fn post_form_no_response_accepts_an_empty_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/oauth/revoke")) - .and(body_string("token=mut_secret")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - client - .post_form_no_response("/oauth/revoke", &[("token", "mut_secret")]) - .await - .unwrap(); - } - - #[tokio::test] - async fn get_deserializes_json_and_injects_bearer_auth() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .and(header("Authorization", "Bearer test-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 42 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Foo = client.get("/foo").await.unwrap(); - assert_eq!(got, Foo { bar: 42 }); - } - - #[tokio::test] - async fn requests_carry_user_agent_header() { - // GitHub's REST API rejects requests with no User-Agent - // ("403 Request forbidden by administrative rules"). The - // reqwest builder doesn't set one by default, so dropping - // the explicit `.user_agent(...)` call would silently - // break every GitHub-backed command. Pin the header value - // to `mergify-cli/` so a regression here - // surfaces as a test failure, not as a prod outage. - let server = MockServer::start().await; - let expected_ua = format!("mergify-cli/{}", env!("CARGO_PKG_VERSION")); - Mock::given(method("GET")) - .and(path("/foo")) - .and(header("User-Agent", expected_ua.as_str())) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 1 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let _: Foo = client.get("/foo").await.unwrap(); - } - - #[tokio::test] - async fn empty_token_skips_auth_header() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bar": 1}))) - .expect(1) - .mount(&server) - .await; - - let client = Client::with_retry_policy( - Url::parse(&server.uri()).unwrap(), - "", - ApiFlavor::GitHub, - RetryPolicy::default(), - ) - .unwrap(); - - let _: Foo = client.get("/foo").await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert_eq!(requests.len(), 1); - assert!( - !requests[0].headers.contains_key("authorization"), - "expected no Authorization header for empty token" - ); - } - - #[tokio::test] - async fn post_no_response_succeeds_on_empty_2xx_body() { - // Mergify endpoints like POST /scopes return an empty body - // on success — `post::` would fail to deserialize. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/empty")) - .and(body_json(Foo { bar: 1 })) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - client - .post_no_response("/empty", &Foo { bar: 1 }) - .await - .unwrap(); - } - - #[tokio::test] - async fn post_empty_if_exists_sends_no_body_and_tolerates_404() { - // `POST .../unstack` is documented as taking no request body, - // so we must not send a JSON `null`; and a 404 (already - // dissolved) satisfies the caller's postcondition. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/present")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/absent")) - .respond_with(ResponseTemplate::new(404).set_body_string("gone")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - client.post_empty_if_exists("/present").await.unwrap(); - client.post_empty_if_exists("/absent").await.unwrap(); - - let requests = server.received_requests().await.unwrap(); - assert!( - requests.iter().all(|r| r.body.is_empty()), - "no request body may be sent", - ); - } - - #[tokio::test] - async fn post_empty_if_exists_propagates_other_4xx() { - // Only 404 is "already done"; a 403 is a real failure the - // caller must see. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/denied")) - .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client.post_empty_if_exists("/denied").await.unwrap_err(); - assert!(matches!(err, CliError::GitHubApi(_)), "got {err:?}"); - } - - #[tokio::test] - async fn post_no_response_propagates_4xx() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/empty")) - .respond_with(ResponseTemplate::new(404).set_body_string("nope")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_no_response("/empty", &Foo { bar: 1 }) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_))); - assert!(err.to_string().contains("404")); - } - - #[tokio::test] - async fn put_no_response_if_exists_reports_whether_the_route_answered() { - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path("/present")) - .and(body_json(serde_json::json!({"bar": 1}))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - Mock::given(method("PUT")) - .and(path("/absent")) - .respond_with(ResponseTemplate::new(404).set_body_string("no such route")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - assert!( - client - .put_no_response_if_exists("/present", &Foo { bar: 1 }) - .await - .unwrap() - ); - // 404 is the caller's branch, and terminal — one request, no - // backoff, so the probe stays cheap. - assert!( - !client - .put_no_response_if_exists("/absent", &Foo { bar: 1 }) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn put_no_response_if_exists_propagates_other_4xx() { - // Only 404 is an answer about the route. A 403 is a real - // failure, and swallowing it as `false` would silently downgrade - // an auth problem into a fallback. - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path("/forbidden")) - .respond_with(ResponseTemplate::new(403).set_body_string("denied")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .put_no_response_if_exists("/forbidden", &Foo { bar: 1 }) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_))); - assert!(err.to_string().contains("403")); - } - - #[tokio::test] - async fn post_classifying_remaps_matched_status() { - // The classifier turns the simulator's 422 into a config - // error (exit 8) instead of the default Mergify API error. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/simulate")) - .respond_with( - ResponseTemplate::new(422) - .set_body_json(serde_json::json!({"detail": "bad config"})), - ) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_classifying::<_, Foo>("/simulate", &Foo { bar: 1 }, |status, msg| { - (status == 422).then(|| CliError::Configuration(msg.to_string())) - }) - .await - .unwrap_err(); - assert!(matches!(err, CliError::Configuration(_)), "got {err:?}"); - assert!(err.to_string().contains("bad config"), "got {err}"); - } - - #[tokio::test] - async fn post_classifying_falls_back_when_status_unmatched() { - // A status the classifier ignores keeps the default flavor - // mapping (Mergify API error here). - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/simulate")) - .respond_with(ResponseTemplate::new(404).set_body_string("nope")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .post_classifying::<_, Foo>("/simulate", &Foo { bar: 1 }, |status, msg| { - (status == 422).then(|| CliError::Configuration(msg.to_string())) - }) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_)), "got {err:?}"); - } - - #[tokio::test] - async fn post_sends_json_body_and_returns_deserialized_response() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/simulate")) - .and(body_json(Foo { bar: 7 })) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 14 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Foo = client.post("/simulate", &Foo { bar: 7 }).await.unwrap(); - assert_eq!(got, Foo { bar: 14 }); - } - - #[tokio::test] - async fn patch_sends_json_body_and_returns_deserialized_response() { - let server = MockServer::start().await; - Mock::given(method("PATCH")) - .and(path("/freeze/abc")) - .and(body_json(Foo { bar: 1 })) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 2 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Foo = client.patch("/freeze/abc", &Foo { bar: 1 }).await.unwrap(); - assert_eq!(got, Foo { bar: 2 }); - } - - struct Flaky { - attempts: Arc, - fail_first: u32, - } - - impl Respond for Flaky { - fn respond(&self, _req: &Request) -> ResponseTemplate { - let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if attempt < self.fail_first { - ResponseTemplate::new(503) - } else { - ResponseTemplate::new(200).set_body_json(Foo { bar: 99 }) - } - } - } - - #[tokio::test] - async fn retries_5xx_then_succeeds() { - let server = MockServer::start().await; - let attempts = Arc::new(AtomicU32::new(0)); - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(Flaky { - attempts: Arc::clone(&attempts), - fail_first: 2, - }) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Foo = client.get("/foo").await.unwrap(); - assert_eq!(got, Foo { bar: 99 }); - assert_eq!(attempts.load(Ordering::SeqCst), 3); - } - - struct RateLimited { - attempts: Arc, - fail_first: u32, - } - - impl Respond for RateLimited { - fn respond(&self, _req: &Request) -> ResponseTemplate { - let attempt = self.attempts.fetch_add(1, Ordering::SeqCst); - if attempt < self.fail_first { - // `Retry-After: 0` so the test honours the header path - // without actually sleeping. - ResponseTemplate::new(429) - .insert_header("retry-after", "0") - .set_body_string("rate limited") - } else { - ResponseTemplate::new(200).set_body_json(Foo { bar: 7 }) - } - } - } - - #[tokio::test] - async fn retries_on_rate_limit_then_succeeds() { - let server = MockServer::start().await; - let attempts = Arc::new(AtomicU32::new(0)); - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(RateLimited { - attempts: Arc::clone(&attempts), - fail_first: 1, - }) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let got: Foo = client.get("/foo").await.unwrap(); - assert_eq!(got, Foo { bar: 7 }); - assert_eq!(attempts.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn bare_403_is_not_retried() { - let server = MockServer::start().await; - // No rate-limit headers → a permission/auth 403, which must - // fail immediately. `expect(1)` fails on server drop if the - // client retried. - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client.get::("/foo").await.unwrap_err(); - assert!(matches!(err, CliError::GitHubApi(_))); - } - - #[tokio::test] - async fn exhausted_retries_on_5xx_yield_mergify_api_error() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(503).set_body_string("service down")) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client.get::("/foo").await.unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_))); - let msg = err.to_string(); - assert!(msg.contains("503"), "expected status in message, got {msg}"); - } - - #[tokio::test] - async fn four_xx_is_not_retried_and_maps_to_github_api_error() { - let server = MockServer::start().await; - // `expect(1)` makes wiremock fail the test if a retry is - // attempted — that's the "not retried" assertion. - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(404).set_body_string("not found")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client.get::("/foo").await.unwrap_err(); - assert!(matches!(err, CliError::GitHubApi(_))); - let msg = err.to_string(); - assert!(msg.contains("404"), "expected status in message, got {msg}"); - } - - #[tokio::test] - async fn json_detail_field_is_extracted_and_url_is_appended() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with( - ResponseTemplate::new(404) - .set_body_json(serde_json::json!({"detail": "Repository not found"})), - ) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let msg = client.get::("/foo").await.unwrap_err().to_string(); - // Clean sentence from `detail`, not the raw `{"detail": …}` body. - assert!( - msg.contains("HTTP 404 Not Found: Repository not found"), - "expected extracted detail, got {msg}" - ); - assert!(!msg.contains('{'), "raw JSON envelope leaked: {msg}"); - // Failing endpoint surfaced on a trailing url: line. - assert!(msg.contains("\nurl: "), "missing url line: {msg}"); - assert!(msg.contains("/foo"), "url should name the path: {msg}"); - } - - #[tokio::test] - async fn non_json_error_body_falls_back_to_raw_text_with_url() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(500).set_body_string("boom")) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let msg = client.get::("/foo").await.unwrap_err().to_string(); - assert!(msg.contains("boom"), "raw body should survive: {msg}"); - assert!(msg.contains("\nurl: "), "missing url line: {msg}"); - } - - #[tokio::test] - async fn join_rejects_absolute_url() { - let server = MockServer::start().await; - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client - .get::("https://evil.example/foo") - .await - .unwrap_err(); - assert!(err.to_string().contains("absolute URLs are not allowed")); - } - - #[tokio::test] - async fn join_rejects_protocol_relative_path() { - let server = MockServer::start().await; - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client.get::("//evil.example/foo").await.unwrap_err(); - assert!(err.to_string().contains("absolute URLs are not allowed")); - } - - /// The leading-slash strip that lets a GHE base survive must not - /// run before the authority guard: `//evil.example/foo` becomes - /// the innocuous-looking `/evil.example/foo` once the slash is - /// gone, so a guard placed after the strip would stop seeing the - /// thing it was written to catch. - #[test] - fn join_rejects_protocol_relative_path_before_stripping_its_slash() { - let client = client_based_at("https://ghe.example.com/api/v3"); - let err = client.join("//evil.example/foo").unwrap_err(); - assert!(err.to_string().contains("absolute URLs are not allowed")); - } - - /// The mirror case: stripping the leading slash can *expose* an - /// absolute URL the pre-strip guard cannot see, since - /// `/https://evil.example/foo` parses as no URL at all while - /// `https://evil.example/foo` swaps the authority outright. - #[test] - fn join_rejects_an_absolute_url_hidden_behind_a_leading_slash() { - let client = client_based_at("https://ghe.example.com/api/v3"); - let err = client.join("/https://evil.example/foo").unwrap_err(); - assert!( - err.to_string().contains("absolute URLs are not allowed"), - "got {err}" - ); - } - - /// GitHub Enterprise Server serves its API under `/api/v3`, and - /// every call site passes an absolute path. Resolved by RFC 3986 - /// that path replaces the base's, so requests landed on the web UI - /// and got redirected to the login page (mergify-cli#1818). - #[test] - fn join_keeps_the_ghe_api_v3_prefix() { - let client = client_based_at("https://ghe.example.com/api/v3"); - assert_eq!( - client.join("/user").unwrap().as_str(), - "https://ghe.example.com/api/v3/user" - ); - assert_eq!( - client.join("/repos/owner/repo/pulls/42").unwrap().as_str(), - "https://ghe.example.com/api/v3/repos/owner/repo/pulls/42" - ); - } - - /// The same thing end to end: a request built from a GHE-shaped - /// base has to arrive under `/api/v3`, not at the root. - #[tokio::test] - async fn get_against_a_ghe_base_requests_the_api_v3_path() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/api/v3/user")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 1 })) - .mount(&server) - .await; - - let client = Client::new( - Url::parse(&format!("{}/api/v3", server.uri())).unwrap(), - "test-token", - ApiFlavor::GitHub, - ) - .unwrap(); - assert_eq!(client.get::("/user").await.unwrap(), Foo { bar: 1 }); - } - - /// A base already carrying its trailing slash resolves the same - /// way — the normalisation must not double it. - #[test] - fn join_does_not_double_a_trailing_slash_on_the_base() { - let client = client_based_at("https://ghe.example.com/api/v3/"); - assert_eq!( - client.join("/user").unwrap().as_str(), - "https://ghe.example.com/api/v3/user" - ); - } - - /// github.com's base has no path prefix, so the same handling has - /// to leave those URLs exactly where they were. - #[test] - fn join_leaves_dotcom_paths_at_the_root() { - let client = client_based_at("https://api.github.com"); - assert_eq!( - client.join("/user").unwrap().as_str(), - "https://api.github.com/user" - ); - assert_eq!( - client.join("/search/issues").unwrap().as_str(), - "https://api.github.com/search/issues" - ); - } - - /// Some call sites hand `join` a path with its query already - /// attached (`mergify-queue`'s merge-queue status). `Url::join` - /// parses that query as a query; building the URL by concatenating - /// onto the base's path instead would percent-encode the `?` into - /// a literal path byte and 404. - #[test] - fn join_keeps_an_embedded_query_a_query() { - let client = client_based_at("https://ghe.example.com/api/v3"); - let url = client - .join("/v1/repos/owner/repo/merge-queue/status?branch=main") - .unwrap(); - assert_eq!(url.path(), "/api/v3/v1/repos/owner/repo/merge-queue/status"); - assert_eq!(url.query(), Some("branch=main")); - } - - #[test] - fn with_retry_policy_rejects_zero_attempts() { - let url = Url::parse("https://api.example/").unwrap(); - let result = Client::with_retry_policy( - url, - "t", - ApiFlavor::Mergify, - RetryPolicy { - max_attempts: 0, - initial_backoff: Duration::from_millis(0), - }, - ); - let Err(err) = result else { - panic!("expected Err for max_attempts=0"); - }; - assert!(err.to_string().contains("max_attempts")); - } - - #[tokio::test] - async fn timeout_yields_did_not_respond_message() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5))) - .mount(&server) - .await; - - // Custom client with a tight request timeout so the test - // provokes a real reqwest timeout in milliseconds rather than - // the production-default 30s. - let inner = reqwest::Client::builder() - .timeout(Duration::from_millis(100)) - .build() - .unwrap(); - let client = Client { - inner, - base_url: Url::parse(&server.uri()).unwrap(), - flavor: ApiFlavor::GitHub, - token: Some("test-token".to_string()), - retry: RetryPolicy { - max_attempts: 1, - initial_backoff: Duration::from_millis(0), - }, - }; - - let err = client.get::("/foo").await.unwrap_err(); - assert!(matches!(err, CliError::GitHubApi(_))); - let msg = err.to_string(); - assert!( - msg.contains("GitHub did not respond in time. The request was aborted — please retry."), - "expected friendly timeout message, got: {msg}" - ); - } - - #[tokio::test] - async fn connect_failure_yields_could_not_reach_message() { - let inner = reqwest::Client::builder() - .timeout(Duration::from_secs(2)) - .build() - .unwrap(); - // Bind, capture port, drop the listener — the port is then - // guaranteed-closed for the duration of the test, so connect - // fails fast with ECONNREFUSED. Avoids hard-coding a port like - // `1` that could happen to be bound on some CI images. - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - drop(listener); - let client = Client { - inner, - base_url: Url::parse(&format!("http://127.0.0.1:{port}/")).unwrap(), - flavor: ApiFlavor::Mergify, - token: Some("t".to_string()), - retry: RetryPolicy { - max_attempts: 1, - initial_backoff: Duration::from_millis(0), - }, - }; - - let err = client.get::("/foo").await.unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_))); - let msg = err.to_string(); - assert!( - msg.contains("could not reach Mergify"), - "expected connect message, got: {msg}" - ); - } - - #[tokio::test] - async fn get_with_query_appends_repeated_keys_and_percent_encodes_values() { - let server = MockServer::start().await; - let client = fast_client(&server, ApiFlavor::Mergify); - - Mock::given(method("GET")) - .and(path("/lookup")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 1 })) - .mount(&server) - .await; - - let _: Foo = client - .get_with_query( - "/lookup", - &[ - ("test_name", "*test login*"), - ("test_name", "a&b?c"), - ("limit", "5"), - ], - ) - .await - .unwrap(); - - let received = server.received_requests().await.unwrap(); - assert_eq!(received.len(), 1); - let raw_query = received[0].url.query().expect("expected a query string"); - // Repeated keys must preserve caller order; query-reserved - // characters (`&`, `?`) must be percent-encoded so the server - // doesn't mistake them for separators. Spaces become `+` (the - // application/x-www-form-urlencoded convention `url` follows). - // `*` is a sub-delim that servers parse literally, so it - // passes through unencoded. - assert_eq!( - raw_query, - "test_name=*test+login*&test_name=a%26b%3Fc&limit=5", - ); - } - - #[tokio::test] - async fn get_with_query_omits_question_mark_when_no_pairs() { - let server = MockServer::start().await; - let client = fast_client(&server, ApiFlavor::Mergify); - - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 0 })) - .mount(&server) - .await; - - let _: Foo = client.get_with_query("/foo", &[]).await.unwrap(); - - let received = server.received_requests().await.unwrap(); - assert_eq!(received.len(), 1); - assert!( - received[0].url.query().is_none(), - "no pairs must produce no `?`, got {:?}", - received[0].url.query(), - ); - } - - #[tokio::test] - async fn error_message_truncates_oversized_body() { - let server = MockServer::start().await; - // Body just past the cap so we exercise the truncation path - // without keeping a giant string in test memory. - let huge = "x".repeat(MAX_ERROR_BODY_BYTES + 1024); - Mock::given(method("GET")) - .and(path("/foo")) - .respond_with(ResponseTemplate::new(404).set_body_string(huge)) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::GitHub); - let err = client.get::("/foo").await.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("[truncated]"), - "expected truncation marker, got len={}", - msg.len() - ); - // The message embeds at most MAX_ERROR_BODY_BYTES of body - // plus a small prefix/suffix; allow some slack for both. - assert!( - msg.len() < MAX_ERROR_BODY_BYTES + 256, - "error message not bounded: len={}", - msg.len() - ); - } - - #[test] - fn next_cursor_from_link_finds_the_next_rel() { - let header = concat!( - "; rel=\"first\", ", - "; rel=\"next\", ", - "; rel=\"last\"", - ); - assert_eq!(next_cursor_from_link(header), Some("abc123".to_string())); - } - - #[test] - fn next_cursor_from_link_accepts_unquoted_rel() { - let header = "; rel=next"; - assert_eq!(next_cursor_from_link(header), Some("zzz".to_string())); - } - - #[test] - fn next_cursor_from_link_is_none_without_a_next_rel() { - let header = "; rel=\"first\""; - assert_eq!(next_cursor_from_link(header), None); - } - - #[test] - fn next_cursor_from_link_is_none_when_the_next_url_has_no_cursor() { - let header = "; rel=\"next\""; - assert_eq!(next_cursor_from_link(header), None); - } - - #[tokio::test] - async fn get_page_returns_body_and_next_cursor() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/paged")) - .respond_with( - ResponseTemplate::new(200) - .insert_header( - "link", - "; rel=\"next\"", - ) - .set_body_json(Foo { bar: 1 }), - ) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let page: Page = client - .get_page("/paged", &[("per_page", "10")]) - .await - .unwrap(); - assert_eq!(page.body, Foo { bar: 1 }); - assert_eq!(page.next_cursor, Some("next-cursor".to_string())); - } - - #[tokio::test] - async fn get_page_has_no_cursor_on_the_last_page() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/paged")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 2 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let page: Page = client.get_page("/paged", &[]).await.unwrap(); - assert_eq!(page.body, Foo { bar: 2 }); - assert_eq!(page.next_cursor, None); - } - - #[tokio::test] - async fn get_page_unless_returns_none_on_the_given_status() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/paged")) - .respond_with(ResponseTemplate::new(402)) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let page: Option> = client.get_page_unless("/paged", &[], 402).await.unwrap(); - assert!(page.is_none()); - } - - #[tokio::test] - async fn get_page_unless_still_retries_and_fails_a_server_error() { - // A 5xx is the server failing, never a routine absence, even - // when the caller names it. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/paged")) - .respond_with(ResponseTemplate::new(503)) - .expect(3) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client - .get_page_unless::("/paged", &[], 503) - .await - .err() - .expect("a 5xx must fail"); - assert!(matches!(err, CliError::MergifyApi(_))); - } - - #[tokio::test] - async fn get_if_exists_returns_some_on_2xx() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/foo")) - .and(header("Authorization", "Bearer test-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(Foo { bar: 7 })) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Option = client.get_if_exists("/foo").await.unwrap(); - assert_eq!(got, Some(Foo { bar: 7 })); - } - - #[tokio::test] - async fn get_if_exists_returns_none_on_404() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/missing")) - .respond_with(ResponseTemplate::new(404).set_body_string("not found")) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Option = client.get_if_exists("/missing").await.unwrap(); - assert!(got.is_none(), "expected None on 404, got {got:?}"); - } - - #[tokio::test] - async fn get_if_exists_surfaces_other_4xx_as_error() { - // 403 / 401 / 422 etc. are real failures, not "doesn't - // exist" — they must surface as `CliError`. Only 404 is - // mapped to `None`. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/forbidden")) - .respond_with(ResponseTemplate::new(403).set_body_string(r#"{"detail":"nope"}"#)) - .expect(1) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let err = client.get_if_exists::("/forbidden").await.unwrap_err(); - assert!( - matches!(err, CliError::MergifyApi(_)), - "expected MergifyApi, got {err:?}", - ); - assert!(err.to_string().contains("403")); - } - - #[tokio::test] - async fn get_if_exists_retries_5xx_then_succeeds() { - // Same retry semantics as `get`: a 500 on the first - // attempt should not short-circuit; the second attempt's - // 200 must be returned as `Some`. - struct FlakyRespond { - calls: Arc, - } - impl Respond for FlakyRespond { - fn respond(&self, _req: &Request) -> ResponseTemplate { - let n = self.calls.fetch_add(1, Ordering::SeqCst); - if n == 0 { - ResponseTemplate::new(500) - } else { - ResponseTemplate::new(200).set_body_json(Foo { bar: 9 }) - } - } - } - let server = MockServer::start().await; - let calls = Arc::new(AtomicU32::new(0)); - Mock::given(method("GET")) - .and(path("/flaky")) - .respond_with(FlakyRespond { - calls: Arc::clone(&calls), - }) - .expect(2) - .mount(&server) - .await; - - let client = fast_client(&server, ApiFlavor::Mergify); - let got: Option = client.get_if_exists("/flaky").await.unwrap(); - assert_eq!(got, Some(Foo { bar: 9 })); - assert_eq!(calls.load(Ordering::SeqCst), 2, "expected two attempts"); - } -} diff --git a/crates/mergify-core/src/lib.rs b/crates/mergify-core/src/lib.rs deleted file mode 100644 index 2673f1da..00000000 --- a/crates/mergify-core/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Shared foundations for the mergify CLI Rust port. -//! -//! - [`exit_code::ExitCode`] — typed exit codes mirroring the -//! Python `exit_codes.py` contract. -//! - [`error::CliError`] — top-level error enum with deterministic -//! mapping to an `ExitCode`. -//! - [`output::Output`] — trait for emitting command results in -//! either JSON or human mode with stdout/stderr discipline baked -//! in. -//! - [`http::Client`] — wraps `reqwest` with bearer auth, retry, -//! and typed error mapping for the Mergify and GitHub APIs. -//! - [`auth`] — resolve `--repository` / `--token` / `--api-url` -//! from the same flag → env → fallback chain the Python CLI uses. -//! - [`credentials::CredentialStore`] — the OS keychain (or a -//! `0600` file) holding the token `mergify auth login` mints, -//! keyed by API URL. -//! - [`command_context::CommandContext`] — bundle the resolved -//! trio + a pre-configured Mergify HTTP client for the -//! queue/freeze command preludes. - -pub mod auth; -pub mod command_context; -pub mod credentials; -pub mod env; -pub mod error; -pub mod exit_code; -pub mod http; -pub mod output; -pub mod pull_request; - -pub use command_context::CommandContext; -pub use credentials::{Credential, CredentialStore}; -pub use error::CliError; -pub use exit_code::ExitCode; -pub use http::{ApiFlavor, ApiOutcome, Client as HttpClient, DeleteOutcome, Page, RetryPolicy}; -pub use output::{Output, OutputMode, StdioOutput}; - -/// Compile-time version string taken from the crate package metadata -/// via ``CARGO_PKG_VERSION``. -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/mergify-core/src/output.rs b/crates/mergify-core/src/output.rs deleted file mode 100644 index 44a18413..00000000 --- a/crates/mergify-core/src/output.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Command output abstraction. -//! -//! Every ported command writes its result through an [`Output`] -//! trait object rather than calling `println!` / `eprintln!` -//! directly. This keeps the JSON and human rendering paths -//! symmetric, lets commands stay test-friendly, and gives a single -//! place to enforce the "stdout must be a single JSON document -//! under `--json`" invariant. -//! -//! The trait is deliberately small. Commands emit one "result" -//! value that knows how to render itself both as JSON (via -//! [`serde::Serialize`]) and as human prose (via a closure). More -//! specialized affordances — progress bars, spinners, tables — are -//! added as ported commands need them. - -use std::io::{self, Write}; - -use serde::Serialize; - -/// Abstraction over the two rendering modes a command can emit -/// into. Commands take `&mut dyn Output` so tests can swap in a -/// fake sink — the stock approach is [`StdioOutput::with_sinks`] -/// plus a custom `Write` (see the test module for an example -/// using `Arc>>`). -pub trait Output { - /// Emit a value as the command's primary result. - /// - /// In JSON mode this serializes `value` and writes it to the - /// stdout sink as a single JSON document. In human mode the - /// provided `human` closure is invoked with the stdout sink - /// instead — this is where the command renders tables, prose, - /// colored output, etc. - /// - /// Commands must call this exactly once per invocation. - fn emit( - &mut self, - value: &dyn ErasedSerialize, - human: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>, - ) -> io::Result<()>; - - /// Emit a progress or status message. In JSON mode this is a - /// no-op to preserve stdout purity; in human mode it writes to - /// stderr so piping stdout into a file is unaffected. - fn status(&mut self, message: &str) -> io::Result<()>; - - /// Emit a raw [`serde_json::Value`] as the command's only output. - /// - /// Convenience wrapper for the `--json` passthrough commands - /// (`queue status`, `queue show`, `freeze list`, …) — every such - /// command needs the same shape: the value goes out as one JSON - /// document, both when the [`OutputMode`] is `Json` and (via the - /// human closure) when the user passed `--json` but the - /// [`OutputMode`] is still `Human`. Implemented in terms of - /// [`Output::emit`]. - fn emit_json_value(&mut self, value: &serde_json::Value) -> io::Result<()> { - self.emit(value, &mut |w| { - let rendered = serde_json::to_string_pretty(value) - .map_err(|err| io::Error::other(err.to_string()))?; - writeln!(w, "{rendered}") - }) - } -} - -/// `dyn Serialize` cannot be constructed directly because -/// `Serialize` is not object-safe. This trait is — any -/// `&T: Serialize` can be passed as `&dyn ErasedSerialize`. -/// -/// Returns a `Result` so serialization failures (custom `Serialize` -/// impls, unrepresentable values, etc.) are surfaced through -/// [`Output::emit`] rather than silently producing `null`. -pub trait ErasedSerialize { - fn to_json_value(&self) -> serde_json::Result; -} - -impl ErasedSerialize for T { - fn to_json_value(&self) -> serde_json::Result { - serde_json::to_value(self) - } -} - -/// Production [`Output`] that writes to the real streams -/// (stdout / stderr). -pub struct StdioOutput { - mode: OutputMode, - stdout: Box, - stderr: Box, -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq)] -pub enum OutputMode { - Human, - Json, -} - -impl StdioOutput { - #[must_use] - pub fn new(mode: OutputMode) -> Self { - Self { - mode, - stdout: Box::new(io::stdout()), - stderr: Box::new(io::stderr()), - } - } - - /// Construct with explicit sinks. Used by tests to capture - /// output. - pub fn with_sinks(mode: OutputMode, stdout: O, stderr: E) -> Self - where - O: Write + Send + 'static, - E: Write + Send + 'static, - { - Self { - mode, - stdout: Box::new(stdout), - stderr: Box::new(stderr), - } - } -} - -impl Output for StdioOutput { - fn emit( - &mut self, - value: &dyn ErasedSerialize, - human: &mut dyn FnMut(&mut dyn Write) -> io::Result<()>, - ) -> io::Result<()> { - match self.mode { - OutputMode::Json => { - let json = value - .to_json_value() - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - let mut rendered = serde_json::to_string_pretty(&json) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; - rendered.push('\n'); - self.stdout.write_all(rendered.as_bytes()) - } - OutputMode::Human => human(&mut *self.stdout), - } - } - - fn status(&mut self, message: &str) -> io::Result<()> { - match self.mode { - OutputMode::Json => Ok(()), - OutputMode::Human => writeln!(self.stderr, "{message}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[derive(Serialize)] - struct Result { - valid: bool, - errors: Vec, - } - - type SharedBytes = std::sync::Arc>>; - - struct Captured { - output: StdioOutput, - stdout: SharedBytes, - stderr: SharedBytes, - } - - fn captured(mode: OutputMode) -> Captured { - let stdout: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let stderr: SharedBytes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let output = StdioOutput::with_sinks( - mode, - SharedWriter(std::sync::Arc::clone(&stdout)), - SharedWriter(std::sync::Arc::clone(&stderr)), - ); - Captured { - output, - stdout, - stderr, - } - } - - struct SharedWriter(SharedBytes); - - impl Write for SharedWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - #[test] - fn json_mode_writes_pretty_json_to_stdout_and_nothing_to_stderr() { - let mut cap = captured(OutputMode::Json); - let value = Result { - valid: false, - errors: vec!["bad thing".to_string()], - }; - cap.output.status("fetching schema…").unwrap(); - cap.output - .emit(&value, &mut |_| { - unreachable!("human closure must not run in JSON mode") - }) - .unwrap(); - - let stdout_bytes = cap.stdout.lock().unwrap().clone(); - let stdout_str = String::from_utf8(stdout_bytes).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&stdout_str).unwrap(); - assert_eq!(parsed["valid"], false); - assert_eq!(parsed["errors"][0], "bad thing"); - - // `status` is a no-op in JSON mode. - assert!(cap.stderr.lock().unwrap().is_empty()); - } - - #[test] - fn human_mode_writes_closure_output_to_stdout_and_status_to_stderr() { - let mut cap = captured(OutputMode::Human); - let value = Result { - valid: true, - errors: vec![], - }; - cap.output.status("fetching schema…").unwrap(); - cap.output - .emit(&value, &mut |w| writeln!(w, "Configuration OK")) - .unwrap(); - - assert_eq!( - String::from_utf8(cap.stdout.lock().unwrap().clone()).unwrap(), - "Configuration OK\n", - ); - assert_eq!( - String::from_utf8(cap.stderr.lock().unwrap().clone()).unwrap(), - "fetching schema…\n", - ); - } -} diff --git a/crates/mergify-core/src/pull_request.rs b/crates/mergify-core/src/pull_request.rs deleted file mode 100644 index be1fee11..00000000 --- a/crates/mergify-core/src/pull_request.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Parse a GitHub-style pull-request URL into its parts. -//! -//! Shared by every command that takes a PR URL on the command line -//! (currently `config simulate`). Lives in `mergify-core` next to -//! `auth`/`http` rather than in any one command crate so all call -//! sites use a single parser instead of copies. - -/// The `(host, owner/repo, number)` triple parsed from a pull-request -/// URL. -/// -/// `host` is kept so callers that talk to the GitHub API (rather than -/// the Mergify API) can derive the right API base — `github.com` vs a -/// GitHub Enterprise Server host. Callers that don't need it (e.g. -/// `config simulate`, which always hits the Mergify API) simply -/// ignore the field. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PullRequestRef { - pub host: String, - pub repository: String, - pub pull_number: u64, -} - -/// Clap value-parser for a positional PR URL argument. -/// -/// Returning `Err(String)` makes clap exit with status 2 (argument -/// validation error) rather than our CLI's `ConfigurationError` — -/// matching the Python CLI's behavior where `_parse_pr_url` raises -/// `click.BadParameter` (also exit 2). -/// -/// # Errors -/// -/// Returns a human-readable message when `url` is not a valid -/// GitHub-style pull request URL. -pub fn parse_pr_url(url: &str) -> Result { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .ok_or_else(|| format!("Invalid pull request URL: {url}"))?; - let parts: Vec<&str> = rest.split('/').collect(); - if parts.len() != 5 || parts[3] != "pull" { - return Err(format!("Invalid pull request URL: {url}")); - } - let [host, owner, repo, _pull, number] = [parts[0], parts[1], parts[2], parts[3], parts[4]]; - if host.is_empty() || owner.is_empty() || repo.is_empty() { - return Err(format!("Invalid pull request URL: {url}")); - } - // A real GitHub PR URL has a bare host with no userinfo. Reject - // `user@host` shapes: a host segment can later be used to build an - // API authority (e.g. `config simulate`'s client), and - // `https://github.com@evil.com/...` parses to host `evil.com` with - // `github.com` as decoy userinfo — which would send the bearer - // token to the attacker host. - if host.contains('@') { - return Err(format!("Invalid pull request URL: {url}")); - } - let pull_number: u64 = number - .parse() - .map_err(|_| format!("Invalid pull request URL: {url}"))?; - Ok(PullRequestRef { - host: host.to_string(), - repository: format!("{owner}/{repo}"), - pull_number, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_pr_url_accepts_canonical_github_url() { - let got = parse_pr_url("https://github.com/owner/repo/pull/42").unwrap(); - assert_eq!(got.host, "github.com"); - assert_eq!(got.repository, "owner/repo"); - assert_eq!(got.pull_number, 42); - } - - #[test] - fn parse_pr_url_keeps_ghes_host() { - let got = parse_pr_url("https://ghe.example.com/owner/repo/pull/7").unwrap(); - assert_eq!(got.host, "ghe.example.com"); - assert_eq!(got.repository, "owner/repo"); - assert_eq!(got.pull_number, 7); - } - - #[test] - fn parse_pr_url_rejects_non_pull_path() { - assert!(parse_pr_url("https://github.com/owner/repo/issues/42").is_err()); - } - - #[test] - fn parse_pr_url_rejects_trailing_segments() { - assert!(parse_pr_url("https://github.com/owner/repo/pull/42/files").is_err()); - } - - #[test] - fn parse_pr_url_rejects_non_numeric_pull_number() { - assert!(parse_pr_url("https://github.com/owner/repo/pull/abc").is_err()); - } - - #[test] - fn parse_pr_url_rejects_missing_scheme() { - assert!(parse_pr_url("github.com/owner/repo/pull/42").is_err()); - } - - #[test] - fn parse_pr_url_rejects_empty_owner() { - assert!(parse_pr_url("https://github.com//repo/pull/42").is_err()); - } - - #[test] - fn parse_pr_url_rejects_userinfo_host() { - // `github.com@evil.com` parses (via Url) to host `evil.com` - // with `github.com` as userinfo — rejecting `@` keeps a - // crafted link from redirecting the GitHub API base (and the - // bearer token) to an attacker host. - assert!(parse_pr_url("https://github.com@evil.com/owner/repo/pull/1").is_err()); - } -} diff --git a/crates/mergify-events/Cargo.toml b/crates/mergify-events/Cargo.toml deleted file mode 100644 index 9143a268..00000000 --- a/crates/mergify-events/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "mergify-events" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Shared client for the Mergify activity log (`/logs`) and the `mergify events` command." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } -mergify-tui = { path = "../mergify-tui" } -anstyle = { workspace = true } -chrono = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } - -[dev-dependencies] -mergify-test-support = { path = "../mergify-test-support" } -tokio = { workspace = true } -url = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-events/src/client.rs b/crates/mergify-events/src/client.rs deleted file mode 100644 index d51e6ea9..00000000 --- a/crates/mergify-events/src/client.rs +++ /dev/null @@ -1,574 +0,0 @@ -//! The `/logs` fetch: explicit window, cursor pagination followed to -//! completion, newest-first guarantee. - -use mergify_core::CliError; -use mergify_core::http::Client; -use serde::Deserialize; - -use crate::event::Event; -use crate::window::Window; - -/// The API's page-size ceiling. -const PER_PAGE_MAX: usize = 100; - -/// A filtered query over a repository's activity log. -pub struct Query { - /// Only events belonging to this pull request; `None` queries the - /// whole repository. - pub pull_request: Option, - /// Only these event types (repeated `event_type` keys, OR - /// semantics); empty selects all. Values pass through verbatim — - /// the server owns the vocabulary, so a type this CLI has never - /// heard of still works against a newer engine. - pub event_types: Vec, - /// The explicit time range — never optional, see [`Window`]. - pub window: Window, - /// Stop after this many events. `None` follows pagination until - /// the window is exhausted. - pub limit: Option, -} - -#[derive(Deserialize)] -struct EventsResponse { - #[serde(default)] - events: Vec, -} - -/// The result of a [`fetch`]: the events, plus whether the limit cut -/// the log short. -/// -/// `truncated` is observed, not inferred: only the fetch knows it -/// stopped on [`Query::limit`] while the window still had more to -/// give. A caller comparing `events.len()` to the limit would call a -/// window holding exactly `limit` events truncated, and say so in an -/// output whose whole job is to state what it did not show. -#[derive(Debug)] -pub struct Log { - /// The window's events, newest first, capped at [`Query::limit`]. - pub events: Vec, - /// The limit stopped the walk with events left in the window. - pub truncated: bool, -} - -/// Fetch every event matching `query`, newest first. -/// -/// Both window bounds are always sent (the API's silent -/// `received_to - 1 day` default is the trap this crate exists to -/// bury), pagination cursors are followed to completion unless -/// [`Query::limit`] stops earlier, and the result is sorted -/// newest-first — a guarantee of this function, not an assumption -/// about the server. Events with no parseable `received_at` sort -/// last, in server order. -/// -/// # Errors -/// -/// Propagates the API failure ([`CliError::MergifyApi`] for HTTP -/// errors). What that means is the caller's call — `queue show` -/// treats it as "could not determine", not as a command failure. -pub async fn fetch(client: &Client, repository: &str, query: &Query) -> Result { - let path = format!("/v1/repos/{repository}/logs"); - let from = query.window.from().to_rfc3339(); - let to = query.window.to().to_rfc3339(); - let pull_request = query.pull_request.map(|n| n.to_string()); - // Sizing the page to the limit is what makes a limited fetch a - // single request: the default limit is one page, so the no-flag - // command asks the API once and stops. - let per_page = query - .limit - .map_or(PER_PAGE_MAX, |limit| limit.clamp(1, PER_PAGE_MAX)) - .to_string(); - - let mut base: Vec<(&str, &str)> = Vec::new(); - if let Some(pull_request) = &pull_request { - base.push(("pull_request", pull_request)); - } - for event_type in &query.event_types { - base.push(("event_type", event_type)); - } - base.push(("received_from", &from)); - base.push(("received_to", &to)); - base.push(("per_page", &per_page)); - - let mut raw_events: Vec = Vec::new(); - let mut cursor: Option = None; - let mut truncated = false; - loop { - let mut pairs = base.clone(); - if let Some(cursor) = &cursor { - pairs.push(("cursor", cursor)); - } - let page = client.get_page::(&path, &pairs).await?; - raw_events.extend(page.body.events); - // A server bug echoing the cursor we just used would - // otherwise loop forever. - let next = page - .next_cursor - .filter(|next| Some(next) != cursor.as_ref()); - if let Some(limit) = query.limit - && raw_events.len() >= limit - { - // Either this page overshot the limit, or the server says - // another page exists: both mean the window outlives what - // we return. - truncated = raw_events.len() > limit || next.is_some(); - break; - } - match next { - Some(next) => cursor = Some(next), - None => break, - } - } - - let mut events: Vec = raw_events.into_iter().map(Event::from_raw).collect(); - // Stable sort: ties (and timestamp-less events, which compare - // smallest and thus sink to the end) keep the server's order. - events.sort_by_key(|event| std::cmp::Reverse(event.received_at_utc())); - if let Some(limit) = query.limit { - events.truncate(limit); - } - Ok(Log { events, truncated }) -} - -#[cfg(test)] -mod tests { - use chrono::DateTime; - use chrono::TimeDelta; - use chrono::Utc; - use mergify_core::http::ApiFlavor; - use serde_json::json; - use url::Url; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - use wiremock::matchers::query_param; - use wiremock::matchers::query_param_is_missing; - - use super::*; - - fn at(iso: &str) -> DateTime { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - } - - fn client(server: &MockServer) -> Client { - Client::new(Url::parse(&server.uri()).unwrap(), "t", ApiFlavor::Mergify).unwrap() - } - - fn event(id: u64, received_at: &str) -> serde_json::Value { - json!({ - "id": id, - "type": "action.queue.leave", - "received_at": received_at, - "metadata": {}, - }) - } - - fn page_body(events: &[serde_json::Value]) -> serde_json::Value { - json!({"size": events.len(), "per_page": 100, "events": events}) - } - - #[tokio::test] - async fn fetch_sends_both_window_bounds_and_every_filter() { - // The explicit window is the whole point: without - // `received_from` the API silently narrows to 1 day, and - // `received_to` rides along so the range is what the caller - // stated, not what the server's clock says. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[]))) - .expect(1) - .mount(&server) - .await; - - let now = at("2026-07-30T00:00:00Z"); - let query = Query { - pull_request: Some(1700), - event_types: vec![ - "action.queue.leave".to_string(), - "command.queue".to_string(), - ], - window: Window::last(TimeDelta::days(7), now).unwrap(), - limit: None, - }; - fetch(&client(&server), "owner/repo", &query).await.unwrap(); - - let received = server.received_requests().await.unwrap(); - let pairs: Vec<(String, String)> = received[0] - .url - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - assert!( - pairs.contains(&("pull_request".into(), "1700".into())), - "got: {pairs:?}", - ); - // Repeated keys, OR semantics — one `event_type` per value. - assert!( - pairs.contains(&("event_type".into(), "action.queue.leave".into())), - "got: {pairs:?}", - ); - assert!( - pairs.contains(&("event_type".into(), "command.queue".into())), - "got: {pairs:?}", - ); - let from = pairs - .iter() - .find(|(k, _)| k == "received_from") - .map(|(_, v)| v.clone()) - .expect("received_from must be explicit, never the API's 1-day default"); - assert_eq!(at(&from), at("2026-07-23T00:00:00Z")); - let to = pairs - .iter() - .find(|(k, _)| k == "received_to") - .map(|(_, v)| v.clone()) - .expect("received_to must be explicit too"); - assert_eq!(at(&to), now); - assert!( - pairs.contains(&("per_page".into(), "100".into())), - "got: {pairs:?}", - ); - } - - #[tokio::test] - async fn fetch_omits_the_pull_request_filter_for_a_repo_wide_query() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param_is_missing("pull_request")) - .and(query_param_is_missing("event_type")) - .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[]))) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let events = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap() - .events; - assert!(events.is_empty()); - } - - #[tokio::test] - async fn fetch_follows_pagination_to_completion() { - // Three pages chained by Link-header cursors: the client must - // walk all of them, not silently return the first. - let server = MockServer::start().await; - let link = |cursor: &str| { - format!("; rel=\"next\"") - }; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param_is_missing("cursor")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", link("c2").as_str()) - .set_body_json(page_body(&[event(3, "2026-07-29T12:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("cursor", "c2")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", link("c3").as_str()) - .set_body_json(page_body(&[event(2, "2026-07-29T11:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("cursor", "c3")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(page_body(&[event(1, "2026-07-29T10:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let events = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap() - .events; - let ids: Vec = events - .iter() - .map(|e| e.raw["id"].as_u64().unwrap()) - .collect(); - assert_eq!(ids, vec![3, 2, 1]); - } - - #[tokio::test] - async fn fetch_stops_at_the_limit_without_following_further_pages() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param_is_missing("cursor")) - .and(query_param("per_page", "2")) - .respond_with( - ResponseTemplate::new(200) - .insert_header( - "link", - "; rel=\"next\"", - ) - .set_body_json(page_body(&[ - event(2, "2026-07-29T12:00:00Z"), - event(1, "2026-07-29T11:00:00Z"), - ])), - ) - .expect(1) - .mount(&server) - .await; - // No mock for cursor=more: requesting it would 404 and fail - // the fetch, so success proves the limit stopped pagination. - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: Some(2), - }; - let log = fetch(&client(&server), "owner/repo", &query).await.unwrap(); - assert_eq!(log.events.len(), 2); - // The server said another page exists, so the window outlives - // what we return — and the caller has to be able to say so. - assert!(log.truncated); - } - - #[tokio::test] - async fn a_window_that_fits_the_limit_exactly_is_not_truncated() { - // The reason `truncated` is observed rather than inferred - // from `events.len() == limit`: a window holding exactly the - // limit is complete, and announcing "newest 2 events" over it - // would be a lie in an output whose job is to state what it - // left out. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[ - event(2, "2026-07-29T12:00:00Z"), - event(1, "2026-07-29T11:00:00Z"), - ]))) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: Some(2), - }; - let log = fetch(&client(&server), "owner/repo", &query).await.unwrap(); - assert_eq!(log.events.len(), 2); - assert!(!log.truncated); - } - - #[tokio::test] - async fn a_page_overshooting_the_limit_is_truncated() { - // No `next` link, but the page held more than the caller - // asked for: the extras are dropped, and dropping them is - // exactly what `truncated` reports. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[ - event(3, "2026-07-29T13:00:00Z"), - event(2, "2026-07-29T12:00:00Z"), - event(1, "2026-07-29T11:00:00Z"), - ]))) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: Some(2), - }; - let log = fetch(&client(&server), "owner/repo", &query).await.unwrap(); - let ids: Vec = log - .events - .iter() - .map(|e| e.raw["id"].as_u64().unwrap()) - .collect(); - // Newest first, so the limit keeps the newest. - assert_eq!(ids, vec![3, 2]); - assert!(log.truncated); - } - - #[tokio::test] - async fn an_unlimited_fetch_is_never_truncated() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(page_body(&[event(1, "2026-07-29T11:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let log = fetch(&client(&server), "owner/repo", &query).await.unwrap(); - assert!(!log.truncated); - } - - #[tokio::test] - async fn fetch_guarantees_newest_first_even_when_the_server_does_not() { - // Ordering is this crate's promise. A server page in the - // wrong order gets re-sorted; a timestamp-less event sinks to - // the end instead of poisoning the sort. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[ - event(1, "2026-07-29T10:00:00Z"), - json!({"id": 99, "type": "mystery"}), - event(3, "2026-07-29T12:00:00Z"), - event(2, "2026-07-29T11:00:00Z"), - ]))) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let events = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap() - .events; - let ids: Vec = events - .iter() - .map(|e| e.raw["id"].as_u64().unwrap()) - .collect(); - assert_eq!(ids, vec![3, 2, 1, 99]); - } - - #[tokio::test] - async fn fetch_survives_a_server_echoing_the_same_cursor() { - // A `next` link pointing at the page we just asked for must - // terminate, not loop forever. - let server = MockServer::start().await; - let echo = "; rel=\"next\""; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param_is_missing("cursor")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", echo) - .set_body_json(page_body(&[event(2, "2026-07-29T12:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("cursor", "stuck")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", echo) - .set_body_json(page_body(&[event(1, "2026-07-29T11:00:00Z")])), - ) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let events = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap() - .events; - assert_eq!(events.len(), 2); - } - - #[tokio::test] - async fn fetch_keeps_unknown_fields_verbatim() { - let server = MockServer::start().await; - let raw = json!({ - "id": 7, - "type": "action.queue.leave", - "received_at": "2026-07-29T12:00:00Z", - "field_from_2027": {"nested": [1, 2, 3]}, - }); - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with( - ResponseTemplate::new(200).set_body_json(page_body(std::slice::from_ref(&raw))), - ) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let events = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap() - .events; - assert_eq!(events[0].raw, raw); - } - - #[tokio::test] - async fn fetch_propagates_the_api_failure() { - // A 403 (token can read the queue but not the whole activity - // log) surfaces as the typed Mergify API error; the caller - // decides whether that is fatal. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({"detail": "nope"}))) - .expect(1) - .mount(&server) - .await; - - let query = Query { - pull_request: None, - event_types: vec![], - window: Window::retained(at("2026-07-30T00:00:00Z")), - limit: None, - }; - let err = fetch(&client(&server), "owner/repo", &query) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_)), "got: {err:?}"); - } -} diff --git a/crates/mergify-events/src/event.rs b/crates/mergify-events/src/event.rs deleted file mode 100644 index dd27080c..00000000 --- a/crates/mergify-events/src/event.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! One activity-log event: the raw API object plus the decoded -//! envelope every event type shares. - -use chrono::DateTime; -use chrono::Utc; -use serde::Deserialize; - -/// An event from the activity log. -/// -/// `raw` is the API's own object, untouched — the schema is -/// Mergify's contract, not this CLI's, so unknown fields must -/// survive the round-trip (`--json` republishes them, and a newer -/// engine cannot break an older CLI). The envelope accessors decode -/// only the fields every event type carries. -#[derive(Debug)] -pub struct Event { - pub raw: serde_json::Value, - envelope: Envelope, -} - -/// The common envelope. Every field is optional: an event shape this -/// CLI has never seen must still render, not crash. -#[derive(Deserialize, Default, Debug)] -struct Envelope { - #[serde(default, rename = "type")] - event_type: Option, - #[serde(default)] - received_at: Option, - #[serde(default)] - trigger: Option, - #[serde(default)] - pull_request: Option, - #[serde(default)] - outcome: Option, -} - -impl Event { - /// Wrap a raw API event. Infallible by design: a payload whose - /// envelope does not decode (wrong types, not an object) is still - /// an event — its accessors read as absent and `raw` keeps - /// everything. - #[must_use] - pub fn from_raw(raw: serde_json::Value) -> Self { - let envelope = Envelope::deserialize(&raw).unwrap_or_default(); - Self { raw, envelope } - } - - /// The event type (e.g. `action.queue.leave`), verbatim from the - /// API — new engine types pass through unrecognized. - #[must_use] - pub fn event_type(&self) -> Option<&str> { - non_empty(self.envelope.event_type.as_deref()) - } - - /// When the engine recorded the event, as the API sent it. - #[must_use] - pub fn received_at(&self) -> Option<&str> { - non_empty(self.envelope.received_at.as_deref()) - } - - /// [`Self::received_at`] parsed, for sorting and rendering. - /// `None` when absent or unparseable — degrading beats crashing - /// on a timestamp format change. - #[must_use] - pub fn received_at_utc(&self) -> Option> { - self.received_at() - .and_then(|ts| DateTime::parse_from_rfc3339(ts).ok()) - .map(|ts| ts.with_timezone(&Utc)) - } - - /// What caused the event (e.g. `merge queue internal`, a command - /// author). - #[must_use] - pub fn trigger(&self) -> Option<&str> { - non_empty(self.envelope.trigger.as_deref()) - } - - /// The pull request the event belongs to, when it has one. - #[must_use] - pub const fn pull_request(&self) -> Option { - self.envelope.pull_request - } - - /// The API's derived outcome label (`success` / `failure` / - /// `pending` / `neutral`). - #[must_use] - pub fn outcome(&self) -> Option<&str> { - non_empty(self.envelope.outcome.as_deref()) - } - - /// The event's type-specific `metadata` object; `Null` when the - /// payload has none. Callers decode the slice of it they - /// understand. - #[must_use] - pub fn metadata(&self) -> &serde_json::Value { - self.raw.get("metadata").unwrap_or(&serde_json::Value::Null) - } -} - -fn non_empty(value: Option<&str>) -> Option<&str> { - value.filter(|s| !s.is_empty()) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn from_raw_decodes_the_shared_envelope() { - let event = Event::from_raw(json!({ - "id": 1, - "type": "action.queue.leave", - "received_at": "2026-07-20T23:25:11.263987Z", - "trigger": "merge queue internal", - "pull_request": 1700, - "metadata": {"merged": false}, - })); - assert_eq!(event.event_type(), Some("action.queue.leave")); - assert_eq!(event.received_at(), Some("2026-07-20T23:25:11.263987Z")); - assert_eq!(event.trigger(), Some("merge queue internal")); - assert_eq!(event.pull_request(), Some(1700)); - assert_eq!(event.metadata()["merged"], json!(false)); - } - - #[test] - fn from_raw_keeps_unknown_fields_verbatim() { - let raw = json!({ - "type": "something.from.2027", - "future_field": {"nested": true}, - }); - let event = Event::from_raw(raw.clone()); - assert_eq!(event.raw, raw); - assert_eq!(event.event_type(), Some("something.from.2027")); - } - - #[test] - fn a_malformed_envelope_still_yields_an_event() { - // Wrong types must not crash — accessors read as absent and - // the raw payload survives for `--json`. - let raw = json!({"type": 42, "received_at": ["not", "a", "date"]}); - let event = Event::from_raw(raw.clone()); - assert_eq!(event.event_type(), None); - assert_eq!(event.received_at(), None); - assert_eq!(event.raw, raw); - } - - #[test] - fn received_at_utc_parses_and_degrades() { - let parsed = Event::from_raw(json!({"received_at": "2026-07-20T23:25:11Z"})); - assert!(parsed.received_at_utc().is_some()); - let garbage = Event::from_raw(json!({"received_at": "not-a-date"})); - assert!(garbage.received_at_utc().is_none()); - } - - #[test] - fn metadata_is_null_when_absent() { - let event = Event::from_raw(json!({"type": "action.label"})); - assert!(event.metadata().is_null()); - } -} diff --git a/crates/mergify-events/src/lib.rs b/crates/mergify-events/src/lib.rs deleted file mode 100644 index 0d5f5349..00000000 --- a/crates/mergify-events/src/lib.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Shared client for the Mergify activity log -//! (`GET /v1/repos///logs`). -//! -//! The activity log carries every event type the engine records — -//! the `action.queue.*` family, the workflow actions, `ci_insights.*`, -//! `command.*`, … — behind one filtered, paginated endpoint. Its -//! contract has sharp edges that every hand-rolled consumer gets -//! subtly wrong, so this crate owns them once: -//! -//! - **The window is always explicit.** The API defaults -//! `received_from` to `received_to - 1 day`, which makes a pull -//! request dequeued last week look identical to one never queued. -//! A [`Window`] carries both bounds and every request sends them; -//! the silent 1-day default is unreachable from here. -//! - **The 93-day cap is a typed error.** The API rejects a range -//! wider than [`MAX_SPAN_DAYS`] with a raw 422 while retention is -//! [`RETENTION_DAYS`] days. [`Window`] refuses to construct such a -//! range ([`WindowError`]), and "everything retained" is spelled -//! [`Window::retained`] — so the 422 cannot happen and the widest -//! useful window has a name. -//! - **Pagination is followed to completion.** The endpoint pages -//! with opaque cursors in RFC 5988 `Link` headers; [`fetch`] -//! follows them until the window is exhausted (or an explicit -//! [`Query::limit`] is reached) rather than silently returning the -//! first page. -//! - **Ordering is guaranteed newest-first.** The API serves events -//! newest-first; [`fetch`] enforces it after collecting, so it is a -//! promise of this crate rather than an observation about today's -//! server. -//! - **Unknown fields pass through verbatim.** An [`Event`] keeps the -//! API's raw object untouched next to a decoded envelope of the -//! fields every event shares — a newer engine cannot break an older -//! CLI, and `--json` consumers see Mergify's contract, not this -//! crate's. -//! -//! On top of the raw client sits the queue-leave **explain layer** -//! ([`queue_leave`]): decode a pull request's last -//! `action.queue.leave` and render why it left — the engine's own -//! reason prose, the failing checks with their job URLs, and the next -//! step. `queue show`'s dequeue diagnosis is a consumer of this -//! crate, not a second implementation of the contract. - -pub mod client; -pub mod event; -pub mod list; -pub mod queue_leave; -pub mod window; - -pub use client::{Query, fetch}; -pub use event::Event; -pub use window::{MAX_SPAN_DAYS, RETENTION_DAYS, Window, WindowError}; diff --git a/crates/mergify-events/src/list.rs b/crates/mergify-events/src/list.rs deleted file mode 100644 index 20f688bd..00000000 --- a/crates/mergify-events/src/list.rs +++ /dev/null @@ -1,1401 +0,0 @@ -//! `mergify events` — browse the repository's activity log as a -//! timeline, repo-wide or for one pull request. -//! -//! One command over a filter covers all ~45 event types; there is -//! deliberately no command per type. Two details carry the lesson -//! the `/logs` contract taught `queue show` (see the crate docs): -//! the header **always states the window**, so an empty result reads -//! as "nothing in the last 24 hours" and never as "nothing ever" — -//! that ambiguity is the bug this command removes — and the empty -//! case says so explicitly, naming the range and the retention. -//! -//! What the default page costs is the other half. This is a -//! low-level query over the API — filtering is `--pr` and `--type`, -//! not a curated feed — so the only thing the default owes the caller -//! is a bound: [`DEFAULT_LIMIT`] events, which is one API page, and a -//! header that says when that bound bit. -//! -//! Two output modes: -//! -//! - Human (default): a header naming the scope, the event count and -//! the exact UTC window, then one line per event, **oldest first** -//! (a timeline reads down the page), with a dim date row wherever -//! the calendar date changes. Each line names the pull request it -//! belongs to, and colors its outcome. -//! - `--json`: a single document with the query echoed -//! (`repository`, `pull_request`, `received_from`, `received_to`), -//! `size`, `truncated`, and `events` — the raw API events **newest -//! first**, unknown fields intact, matching the client's ordering -//! guarantee. - -use std::io::Write; - -use anstyle::Style; -use chrono::DateTime; -use chrono::TimeDelta; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use mergify_tui::Theme; -use serde_json::Value; - -use crate::client; -use crate::client::Query; -use crate::event::Event; -use crate::window::MAX_SPAN_DAYS; -use crate::window::RETENTION_DAYS; -use crate::window::Window; - -/// The window used when `--since` is not given. Matches the API's -/// own default — but stated in the output instead of silently -/// applied, which is the difference that matters. -const DEFAULT_SINCE: TimeDelta = TimeDelta::hours(24); - -/// How many events the default invocation fetches — exactly one API -/// page. -/// -/// The window alone is not a budget: a busy repository records -/// thousands of events a day, so `--since 24h` with no cap walked -/// every page (100 at a time) before printing a line, and the no-flag -/// command took minutes. One page is one request, and — this is the -/// part that makes a default cap acceptable — the header says it is -/// the *newest* N and names `--limit` as the way past it. Nothing is -/// hidden, only deferred. -pub const DEFAULT_LIMIT: usize = 100; - -pub struct ListOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - /// Only this pull request's events; `None` covers the repository. - pub pr_number: Option, - /// How far back to look; `None` applies [`DEFAULT_SINCE`]. - pub since: Option, - /// `event_type` filters, passed to the API verbatim (repeatable, - /// OR semantics); empty selects every type. - pub event_types: Vec, - /// Stop after the newest N events; the CLI defaults it to - /// [`DEFAULT_LIMIT`]. The header says so when it takes effect. - pub limit: usize, - pub output_json: bool, -} - -/// Parse a `--since` duration: an integer with a unit — `s`, `m`, -/// `h`, `d`, or `w` (e.g. `30m`, `12h`, `7d`). -/// -/// The span cap is enforced here so an impossible window dies as a -/// usage error with the fix in it, not as an API 422 later. -/// -/// # Errors -/// -/// A message suitable for clap's `invalid value` report. -pub fn parse_since(value: &str) -> Result { - let value = value.trim(); - let (number, unit) = value.split_at(value.len().saturating_sub(1)); - let count: i64 = - number.parse().ok().filter(|n| *n > 0).ok_or_else(|| { - "expected a positive integer with a unit, e.g. 30m, 12h, 7d".to_string() - })?; - let span = match unit { - "s" => TimeDelta::seconds(count), - "m" => TimeDelta::minutes(count), - "h" => TimeDelta::hours(count), - "d" => TimeDelta::days(count), - "w" => TimeDelta::weeks(count), - other => { - return Err(format!( - "unknown unit {other:?}: use s, m, h, d or w, e.g. 30m, 12h, 7d" - )); - } - }; - if span > TimeDelta::days(MAX_SPAN_DAYS) { - return Err(format!( - "the activity log retains {RETENTION_DAYS} days and the API rejects \ - windows over {MAX_SPAN_DAYS} days — use {RETENTION_DAYS}d or less" - )); - } - Ok(span) -} - -/// Run the `events` command. -pub async fn run(opts: ListOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - run_at(opts, Utc::now(), output).await -} - -/// [`run`] with an injected clock, the testable seam — the header -/// and the window bounds derive from `now`. -pub async fn run_at( - opts: ListOptions<'_>, - now: DateTime, - output: &mut dyn Output, -) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - let client = ctx.mergify_client()?; - - let window = Window::last(opts.since.unwrap_or(DEFAULT_SINCE), now) - // `parse_since` already enforced the cap; anything left is a - // caller bug surfaced as the typed message rather than a panic. - .map_err(|e| CliError::InvalidState(e.to_string()))?; - - let scope = match opts.pr_number { - Some(n) => format!("PR #{n}"), - None => ctx.repository.clone(), - }; - output.status(&format!("Fetching events for {scope}…"))?; - - let query = Query { - pull_request: opts.pr_number, - event_types: opts.event_types.clone(), - window, - limit: Some(opts.limit), - }; - let log = client::fetch(&client, &ctx.repository, &query).await?; - - if opts.output_json { - return emit_json(output, &ctx.repository, &opts, &window, &log); - } - - let theme = Theme::detect(); - let timeline = Timeline { - scope: &scope, - window: &window, - events: &log.events, - truncated: log.truncated, - // Repo-wide, the pull request is the only thing telling two - // `action.queue.checks.change` lines apart. Under `--pr` the - // header already named it, so the column would be a stutter. - show_pull_request: opts.pr_number.is_none(), - }; - output.emit(&(), &mut |w: &mut dyn Write| render(w, &theme, &timeline))?; - Ok(()) -} - -/// `--json`: the query echoed back plus the raw events, newest -/// first. Echoing `received_from`/`received_to` keeps the -/// anti-ambiguity contract for machine consumers too — an empty -/// `events` names the window it is empty *over*, and `truncated` says -/// whether the window holds more than `events` shows, so a script -/// reading `size` is never quietly reading a default cap. -fn emit_json( - output: &mut dyn Output, - repository: &str, - opts: &ListOptions<'_>, - window: &Window, - log: &client::Log, -) -> Result<(), CliError> { - let raw: Vec<&Value> = log.events.iter().map(|event| &event.raw).collect(); - output.emit_json_value(&serde_json::json!({ - "repository": repository, - "pull_request": opts.pr_number, - "received_from": window.from().to_rfc3339(), - "received_to": window.to().to_rfc3339(), - "size": log.events.len(), - "truncated": log.truncated, - "events": raw, - }))?; - Ok(()) -} - -/// One rendered result: what the human renderer needs beyond the -/// events themselves. -struct Timeline<'a> { - /// The repository, or `PR #N` — whatever the header names. - scope: &'a str, - window: &'a Window, - events: &'a [Event], - /// The limit stopped the fetch with events left in the window. - truncated: bool, - /// Name each event's pull request in its own column. - show_pull_request: bool, -} - -/// Whether [`write_header`] already said everything there was to say. -#[derive(PartialEq, Eq)] -enum Header { - WasEmpty, - EventsFollow, -} - -/// The scope, the count, the window, and every caveat attached to -/// them. Each caveat states a cut this page made and the flag that -/// undoes it: the limit at the old end, the retention behind the -/// window. -fn write_header( - w: &mut dyn Write, - theme: &Theme, - timeline: &Timeline<'_>, -) -> std::io::Result
{ - let &Timeline { - scope, - window, - events, - truncated, - .. - } = timeline; - let from = format_minute(window.from()); - let to = format_minute(window.to()); - - if events.is_empty() { - // The empty case must name the window: "nothing in the last - // 24h" and "nothing ever" are different answers, and the - // second one is not this command's to give. - writeln!(w, "No events for {scope} between {from} and {to} UTC.")?; - if window.from() > window.to() - TimeDelta::days(RETENTION_DAYS) { - writeln!( - w, - "{D}Retention is {RETENTION_DAYS} days; try --since {RETENTION_DAYS}d.{R}", - D = theme.dim, - R = theme.reset, - )?; - } - return Ok(Header::WasEmpty); - } - - let count = if truncated { - format!("newest {n} events", n = events.len()) - } else if events.len() == 1 { - "1 event".to_string() - } else { - format!("{n} events", n = events.len()) - }; - writeln!( - w, - "{B}{scope}{R} {D}· {count} · {from} → {to} UTC{R}", - B = theme.bold, - R = theme.reset, - D = theme.dim, - )?; - if truncated { - // The cut is at the *old* end, which — the timeline reading - // down the page — is the top of the list. Saying so where the - // cut is, and naming the flag that undoes it, is what keeps a - // default cap from reading as "that was everything". - writeln!( - w, - "{D}Older events in this window were not fetched — raise --limit.{R}", - D = theme.dim, - R = theme.reset, - )?; - } - writeln!(w)?; - Ok(Header::EventsFollow) -} - -fn render(w: &mut dyn Write, theme: &Theme, timeline: &Timeline<'_>) -> std::io::Result<()> { - let &Timeline { - window, - events, - show_pull_request, - .. - } = timeline; - if write_header(w, theme, timeline)? == Header::WasEmpty { - return Ok(()); - } - - let type_width = events - .iter() - .map(|event| event.event_type().unwrap_or("?").chars().count()) - .max() - .unwrap_or(0); - let pr_width = if show_pull_request { - events - .iter() - .filter_map(|event| Some(pr_cell(event)?.chars().count())) - .max() - .unwrap_or(0) - } else { - 0 - }; - - // Oldest first: a timeline reads down the page. Date rows appear - // wherever the calendar date changes — and before the first event - // when the window spans more than one date, where a bare time - // would be ambiguous. - let mut current_date: Option = None; - let multi_date = window.from().date_naive() != window.to().date_naive(); - for event in events.iter().rev() { - let stamp = event.received_at_utc(); - if let Some(date) = stamp.map(|ts| ts.format("%Y-%m-%d").to_string()) { - let changed = current_date.as_ref().is_some_and(|d| *d != date); - if changed || (current_date.is_none() && multi_date) { - writeln!(w, " {D}{date}{R}", D = theme.dim, R = theme.reset)?; - } - current_date = Some(date); - } - let time = stamp.map_or_else(|| "--:--".to_string(), |ts| ts.format("%H:%M").to_string()); - write!(w, " {D}{time}{R}", D = theme.dim, R = theme.reset)?; - if pr_width > 0 { - match pr_cell(event) { - Some(pr) => write!( - w, - " {N}{pr: write!(w, " {blank: writeln!( - w, - " {S}{text}{R}", - S = tone_style(theme, summary.tone), - text = summary.text, - R = theme.reset, - )?, - None => writeln!(w)?, - } - } - Ok(()) -} - -/// The event's pull request as it prints, or `None` for the -/// repository-level events that have none. -fn pr_cell(event: &Event) -> Option { - event.pull_request().map(|number| format!("#{number}")) -} - -fn format_minute(ts: DateTime) -> String { - ts.format("%Y-%m-%d %H:%M").to_string() -} - -/// How a summary reads. The colour *is* the classification — a -/// timeline where `success` and `failure` are the same grey makes -/// the reader parse every word to find the one that matters. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Tone { - /// A fact with no verdict in it: a queue name, a command author. - Muted, - Good, - /// Worth a look, but not a failure — the merge queue interrupted - /// the checks, or something is still running. - Warn, - Bad, -} - -const fn tone_style(theme: &Theme, tone: Tone) -> Style { - match tone { - Tone::Muted => theme.dim, - Tone::Good => theme.green, - Tone::Warn => theme.yellow, - Tone::Bad => theme.red, - } -} - -/// One event line's trailing cell: the text and what it means. -struct Summary { - text: String, - tone: Tone, -} - -impl Summary { - fn muted(text: String) -> Self { - Self { - text, - tone: Tone::Muted, - } - } - - const fn toned(text: String, tone: Tone) -> Self { - Self { text, tone } - } -} - -/// One best-effort hint per event line, read from the metadata each -/// type is known to carry. Everything here degrades: an unknown type -/// (or a known type missing a field) just renders without a summary — -/// `--json` has the whole payload. -/// -/// Which types earn an arm is measured, not guessed. Over 500 -/// consecutive events on `Mergifyio/monorepo`, 93% were the two queue -/// state-change types below (`action.queue.change` 233, -/// `action.queue.checks.change` 232) — neither carries a pull request, -/// so without their metadata a default page is a wall of the same two -/// lines. The rest of the arms cover everything else that appeared -/// more than once: `action.label`, `action.request_reviews`, -/// `action.comment`. -fn summary(event: &Event) -> Option { - // A type this function knows about can still arrive without the - // field it knows about — from an older engine, or from a payload - // shape that changed. That is a miss, not a reason to print - // nothing: fall back to what every event has. - from_metadata(event).or_else(|| { - outcome_summary(event).or_else(|| event.trigger().map(str::to_owned).map(Summary::muted)) - }) -} - -/// The per-type arm of [`summary`]; `None` means "nothing specific to -/// say", which the caller turns into the generic fallback. -fn from_metadata(event: &Event) -> Option { - let meta = event.metadata(); - let text = |key: &str| { - meta.get(key) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - }; - let flag = |key: &str| meta.get(key).and_then(Value::as_bool).unwrap_or(false); - let count = |key: &str| meta.get(key).and_then(Value::as_u64); - // The queue-state events carry no pull request: the queue they - // moved and the depth they moved it to is all they are. - let queue_state = |unit: &str, key: &str| { - let queue = text("queue_name")?; - let n = count(key)?; - Some(Summary::muted(format!( - "{queue} · {n} {unit}{s}", - s = if n == 1 { "" } else { "s" }, - ))) - }; - - match event.event_type().unwrap_or_default() { - "action.queue.change" => queue_state("PR", "size"), - "action.queue.checks.change" => queue_state("check", "running_checks"), - // Which labels, not that labelling happened: the trigger this - // falls back to otherwise ("Rule: label on unresolved") is the - // same string on every line the rule ever fired. - "action.label" => { - let mut parts = list_of(meta.get("added"), "+"); - parts.extend(list_of(meta.get("removed"), "-")); - (!parts.is_empty()).then(|| Summary::muted(parts.join(" "))) - } - "action.request_reviews" => { - let mut parts = list_of(meta.get("reviewers"), "@"); - parts.extend(list_of(meta.get("team_reviewers"), "@")); - (!parts.is_empty()).then(|| Summary::muted(parts.join(" "))) - } - // What was posted, first line only — a comment body is - // multi-line and this is one column of a timeline. - "action.comment" => text("message").map(|message| Summary::muted(first_line(&message))), - "action.queue.enter" => text("queue_name").map(Summary::muted), - "action.queue.checks_start" => { - let mut parts: Vec = Vec::new(); - parts.extend(text("queue_name")); - if let Some(draft) = meta - .get("speculative_check_pull_request") - .and_then(Value::as_u64) - { - parts.push(format!("draft PR #{draft}")); - } - (!parts.is_empty()).then(|| Summary::muted(parts.join(" · "))) - } - // The abort codes riding on checks_end interrupt the checks - // while the PR stays queued — naming them here is what keeps - // a reader from mistaking one for a dequeue. Yellow, not red, - // carries that same distinction at a glance: red is reserved - // for the leave below, which is the one that ejected the PR. - "action.queue.checks_end" if flag("aborted") => { - text("abort_code").map(|code| Summary::toned(code, Tone::Warn)) - } - "action.queue.leave" => { - if flag("merged") { - Some(Summary::toned("merged".to_string(), Tone::Good)) - } else { - // Red matches `queue show`, which paints the same - // dequeue the same way. - text("dequeue_code").map(|code| Summary::toned(code, Tone::Bad)) - } - } - // Who ran it is the fact about a command; its outcome, which - // the fallback would prefer, is about what the command asked - // for and shows up on the action events that follow. - t if t.starts_with("command.") => event.trigger().map(str::to_owned).map(Summary::muted), - _ => None, - } -} - -/// A metadata array of strings, each prefixed — skipping the empty -/// arrays the API sends for the half of a pair that didn't happen -/// (`{"reviewers": [], "team_reviewers": ["devs"]}`). -fn list_of(value: Option<&Value>, prefix: &str) -> Vec { - value - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(|item| format!("{prefix}{item}")) - .collect() - }) - .unwrap_or_default() -} - -/// The first line, clipped to what a timeline column can hold. Counts -/// characters, not bytes: the messages this renders routinely carry -/// emoji, and a byte slice would panic mid-codepoint. -fn first_line(text: &str) -> String { - const MAX: usize = 72; - let line = text.lines().next().unwrap_or_default().trim(); - if line.chars().count() <= MAX { - return line.to_owned(); - } - let kept: String = line.chars().take(MAX - 1).collect(); - format!("{kept}…") -} - -/// The API's derived outcome, coloured by what it says. -/// -/// `neutral` is dropped rather than printed: it is the API's word -/// for "this event has no verdict", and a repo-wide timeline of -/// `action.queue.checks.change neutral` repeated twenty times is -/// exactly the noise that hid the real events. Falling through to -/// the trigger says something instead. -fn outcome_summary(event: &Event) -> Option { - let outcome = event.outcome()?; - let tone = match outcome { - "success" => Tone::Good, - "failure" | "error" | "cancelled" => Tone::Bad, - "pending" => Tone::Warn, - "neutral" => return None, - // An outcome word from a newer engine still prints, just - // without a claim about what it means. - _ => Tone::Muted, - }; - Some(Summary::toned(outcome.to_owned(), tone)) -} - -#[cfg(test)] -mod tests { - use mergify_core::OutputMode; - use mergify_test_support::Captured; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - use wiremock::matchers::query_param; - use wiremock::matchers::query_param_is_missing; - - use super::*; - - fn at(iso: &str) -> DateTime { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - } - - fn queue_lifecycle() -> Vec { - // Newest first, as the API serves them. - vec![ - json!({ - "id": 5, - "type": "command.queue", - "received_at": "2026-07-30T15:12:00Z", - "trigger": "@jd", - "pull_request": 1740, - "metadata": {}, - }), - json!({ - "id": 4, - "type": "action.queue.leave", - "received_at": "2026-07-30T15:04:00Z", - "trigger": "merge queue internal", - "pull_request": 1740, - "metadata": {"merged": false, "dequeue_code": "CHECKS_FAILED"}, - }), - json!({ - "id": 3, - "type": "action.queue.checks_end", - "received_at": "2026-07-30T15:04:00Z", - "outcome": "failure", - "pull_request": 1740, - "metadata": {"aborted": false}, - }), - json!({ - "id": 2, - "type": "action.queue.checks_start", - "received_at": "2026-07-30T14:31:00Z", - "pull_request": 1740, - "metadata": {"queue_name": "default", "speculative_check_pull_request": 1801}, - }), - json!({ - "id": 1, - "type": "action.queue.enter", - "received_at": "2026-07-30T14:02:00Z", - "pull_request": 1740, - "metadata": {"queue_name": "default"}, - }), - ] - } - - async fn arrange(server: &MockServer, events: Vec) { - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "size": events.len(), - "per_page": 100, - "events": events, - }))) - .expect(1) - .mount(server) - .await; - } - - /// The knobs the tests vary; everything else is fixed. `limit: - /// None` is the no-flag invocation — the CLI's own default. - #[derive(Default)] - struct Args { - pr_number: Option, - since: Option, - event_types: Vec, - limit: Option, - } - - impl Args { - fn pr(number: u64) -> Self { - Self { - pr_number: Some(number), - ..Self::default() - } - } - } - - async fn run_list(server: &MockServer, args: Args, output_json: bool) -> Captured { - let api_url = server.uri(); - let mut cap = if output_json { - Captured::new(OutputMode::Json) - } else { - Captured::human() - }; - run_at( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number: args.pr_number, - since: args.since, - event_types: args.event_types, - limit: args.limit.unwrap_or(DEFAULT_LIMIT), - output_json, - }, - at("2026-07-30T21:00:00Z"), - &mut cap.output, - ) - .await - .unwrap(); - cap - } - - #[tokio::test] - async fn human_header_states_scope_count_and_window() { - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list(&server, Args::pr(1740), false).await; - let stdout = cap.stdout(); - // The window is the whole point: an empty-looking day must - // never read as an empty history. - assert!( - stdout.contains("PR #1740 · 5 events · 2026-07-29 21:00 → 2026-07-30 21:00 UTC"), - "got: {stdout}", - ); - } - - #[tokio::test] - async fn human_timeline_reads_oldest_first_with_summaries() { - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list(&server, Args::pr(1740), false).await; - let stdout = cap.stdout(); - let enter = stdout.find("action.queue.enter").unwrap(); - let leave = stdout.find("action.queue.leave").unwrap(); - let requeue = stdout.find("command.queue").unwrap(); - assert!( - enter < leave && leave < requeue, - "a timeline reads down the page: {stdout}", - ); - assert!(stdout.contains("14:02"), "got: {stdout}"); - // Per-type summaries from metadata. - assert!(stdout.contains("default · draft PR #1801"), "got: {stdout}"); - assert!(stdout.contains("CHECKS_FAILED"), "got: {stdout}"); - assert!(stdout.contains("failure"), "got: {stdout}"); - assert!(stdout.contains("@jd"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_timeline_marks_date_changes() { - let server = MockServer::start().await; - arrange( - &server, - vec![ - json!({ - "id": 2, - "type": "action.merge", - "received_at": "2026-07-30T09:00:00Z", - "outcome": "success", - }), - json!({ - "id": 1, - "type": "action.queue.enter", - "received_at": "2026-07-29T22:00:00Z", - "metadata": {"queue_name": "default"}, - }), - ], - ) - .await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - // Both dates appear as rows: the window spans two dates, so a - // bare `22:00` would be ambiguous. - assert!(stdout.contains(" 2026-07-29\n"), "got: {stdout}"); - assert!(stdout.contains(" 2026-07-30\n"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_repo_wide_header_names_the_repository() { - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("owner/repo · 5 events"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_empty_names_the_window_and_the_retention() { - // The bug this command removes: an empty result must read as - // "nothing in this window", never "nothing ever". - let server = MockServer::start().await; - arrange(&server, vec![]).await; - - let cap = run_list(&server, Args::pr(1740), false).await; - let stdout = cap.stdout(); - assert!( - stdout.contains( - "No events for PR #1740 between 2026-07-29 21:00 and 2026-07-30 21:00 UTC." - ), - "got: {stdout}", - ); - assert!( - stdout.contains("Retention is 90 days; try --since 90d."), - "got: {stdout}", - ); - } - - #[tokio::test] - async fn human_empty_at_full_retention_offers_no_wider_window() { - // `--since 90d` came back empty: there is nothing wider to - // suggest, and suggesting it anyway would be noise. - let server = MockServer::start().await; - arrange(&server, vec![]).await; - - let cap = run_list( - &server, - Args { - since: Some(TimeDelta::days(90)), - ..Args::pr(1740) - }, - false, - ) - .await; - let stdout = cap.stdout(); - assert!(stdout.contains("No events for PR #1740"), "got: {stdout}"); - assert!(!stdout.contains("try --since"), "got: {stdout}"); - } - - /// `n` events, newest first, one a minute back from noon — enough - /// of them to outrun the default cap. - fn many(n: usize) -> Vec { - let newest = at("2026-07-30T12:00:00Z"); - (0..n) - .map(|i| { - let received_at = newest - TimeDelta::minutes(i64::try_from(i).unwrap()); - json!({ - "id": n - i, - "type": "action.merge", - "received_at": received_at.to_rfc3339(), - "pull_request": 1700 + i, - "outcome": "success", - }) - }) - .collect() - } - - #[tokio::test] - async fn human_header_says_newest_when_the_limit_bites() { - // A silent cap would read as "that's everything"; the header - // must say the list was cut. - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list( - &server, - Args { - limit: Some(2), - ..Args::pr(1740) - }, - false, - ) - .await; - let stdout = cap.stdout(); - assert!(stdout.contains("newest 2 events"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_truncation_names_the_flag_that_undoes_it() { - // Saying "newest 100" without saying how to see the rest - // leaves the reader with a truthful dead end. This is also - // the no-flag path: one page over, one page printed. - let server = MockServer::start().await; - arrange(&server, many(DEFAULT_LIMIT + 5)).await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - assert!( - stdout.contains(&format!("newest {DEFAULT_LIMIT} events")), - "got: {stdout}", - ); - assert!(stdout.contains("Older events"), "got: {stdout}"); - assert!(stdout.contains("--limit"), "got: {stdout}"); - assert_eq!(stdout.matches("action.merge").count(), DEFAULT_LIMIT); - } - - #[tokio::test] - async fn human_says_nothing_about_truncation_when_the_window_fit() { - // The window held fewer events than the cap: claiming a cut - // would be as wrong as hiding one. - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("owner/repo · 5 events"), "got: {stdout}"); - assert!(!stdout.contains("Older events"), "got: {stdout}"); - assert!(!stdout.contains("newest"), "got: {stdout}"); - } - - #[tokio::test] - async fn the_default_invocation_asks_for_one_page_and_stops() { - // The bug: no flags meant no limit, so a busy repository's - // 24 hours was walked page by page before anything printed. - // The default is one page — `per_page=100` and `expect(1)` - // are the assertion, whatever window is left unread. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("per_page", DEFAULT_LIMIT.to_string().as_str())) - .and(query_param_is_missing("cursor")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", "; rel=\"next\"") - .set_body_json(json!({ - "size": DEFAULT_LIMIT, - "per_page": DEFAULT_LIMIT, - "events": many(DEFAULT_LIMIT), - })), - ) - .expect(1) - .mount(&server) - .await; - - let cap = run_list(&server, Args::default(), false).await; - assert!( - cap.stdout() - .contains(&format!("newest {DEFAULT_LIMIT} events")), - "got: {}", - cap.stdout(), - ); - } - - #[tokio::test] - async fn a_limit_past_one_page_keeps_walking() { - // `--limit N` is the only way past the default, so it has to - // reach beyond the single page the default stops at: pages - // stay capped at the API's 100, and the walk continues until - // the limit or the window runs out. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("per_page", "100")) - .and(query_param_is_missing("cursor")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("link", "; rel=\"next\"") - .set_body_json(json!({"size": 1, "per_page": 100, "events": [json!({ - "id": 2, - "type": "action.merge", - "received_at": "2026-07-30T12:00:00Z", - "outcome": "success", - })]})), - ) - .expect(1) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .and(query_param("cursor", "c2")) - .respond_with(ResponseTemplate::new(200).set_body_json( - json!({"size": 1, "per_page": 100, "events": [json!({ - "id": 1, - "type": "action.merge", - "received_at": "2026-07-30T11:00:00Z", - "outcome": "success", - })]}), - )) - .expect(1) - .mount(&server) - .await; - - let cap = run_list( - &server, - Args { - limit: Some(DEFAULT_LIMIT * 2), - ..Args::default() - }, - false, - ) - .await; - let stdout = cap.stdout(); - // Both pages walked, and nothing claims a cut. - assert!(stdout.contains("2 events"), "got: {stdout}"); - assert!(!stdout.contains("Older events"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_repo_wide_lines_name_their_pull_request() { - // Twenty `action.label` lines are the same line until each - // says whose it is. - let server = MockServer::start().await; - arrange( - &server, - vec![ - json!({ - "id": 2, - "type": "action.label", - "received_at": "2026-07-30T12:00:00Z", - "pull_request": 1801, - }), - json!({ - "id": 1, - "type": "action.label", - "received_at": "2026-07-30T11:00:00Z", - "pull_request": 1740, - }), - ], - ) - .await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("#1740"), "got: {stdout}"); - assert!(stdout.contains("#1801"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_pr_scope_leaves_the_pull_request_column_out() { - // The header already said `PR #1740`; a column repeating it - // on every line is noise. - let server = MockServer::start().await; - arrange(&server, queue_lifecycle()).await; - - let cap = run_list(&server, Args::pr(1740), false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("PR #1740 · 5 events"), "got: {stdout}"); - let body = stdout.split_once("UTC\n").unwrap().1; - assert!(!body.contains("#1740"), "got: {stdout}"); - } - - #[tokio::test] - async fn human_holds_the_column_for_a_repository_level_event() { - // A freeze belongs to no pull request: the column stays, - // empty, so the ones that have a PR still line up. - let server = MockServer::start().await; - arrange( - &server, - vec![ - json!({ - "id": 2, - "type": "action.queue.enter", - "received_at": "2026-07-30T12:00:00Z", - "pull_request": 1740, - "metadata": {"queue_name": "default"}, - }), - json!({ - "id": 1, - "type": "queue.freeze.create", - "received_at": "2026-07-30T11:00:00Z", - "metadata": {}, - }), - ], - ) - .await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - let freeze = stdout - .lines() - .find(|line| line.contains("queue.freeze.create")) - .unwrap(); - let enter = stdout - .lines() - .find(|line| line.contains("action.queue.enter")) - .unwrap(); - assert_eq!( - freeze.find("queue.freeze.create"), - enter.find("action.queue.enter"), - "the type column must start at the same offset: {stdout}", - ); - } - - #[tokio::test] - async fn human_survives_an_unknown_event_type() { - let server = MockServer::start().await; - arrange( - &server, - vec![json!({ - "id": 1, - "type": "something.from.2027", - "received_at": "2026-07-30T14:00:00Z", - "metadata": {"mystery": true}, - })], - ) - .await; - - let cap = run_list(&server, Args::default(), false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("something.from.2027"), "got: {stdout}"); - assert!(stdout.contains("14:00"), "got: {stdout}"); - } - - #[tokio::test] - async fn json_echoes_the_query_and_republishes_raw_events_newest_first() { - let server = MockServer::start().await; - let events = queue_lifecycle(); - arrange(&server, events.clone()).await; - - let cap = run_list(&server, Args::pr(1740), true).await; - let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!( - parsed, - json!({ - "repository": "owner/repo", - "pull_request": 1740, - "received_from": "2026-07-29T21:00:00+00:00", - "received_to": "2026-07-30T21:00:00+00:00", - "size": 5, - "truncated": false, - "events": events, - }), - ); - } - - #[tokio::test] - async fn json_says_when_the_default_limit_cut_the_window() { - // A script reading `size` must be able to tell "that was the - // window" from "that was the cap". - let server = MockServer::start().await; - arrange(&server, many(DEFAULT_LIMIT + 5)).await; - - let cap = run_list(&server, Args::default(), true).await; - let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["truncated"], json!(true)); - } - - #[tokio::test] - async fn json_keeps_unknown_fields_intact() { - let server = MockServer::start().await; - let raw = json!({ - "id": 1, - "type": "action.queue.leave", - "received_at": "2026-07-30T14:00:00Z", - "field_from_2027": {"nested": [1, 2, 3]}, - }); - arrange(&server, vec![raw.clone()]).await; - - let cap = run_list(&server, Args::default(), true).await; - let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["events"][0], raw); - } - - #[tokio::test] - async fn json_empty_still_names_the_window() { - let server = MockServer::start().await; - arrange(&server, vec![]).await; - - let cap = run_list(&server, Args::default(), true).await; - let parsed: Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["size"], json!(0)); - assert_eq!(parsed["events"], json!([])); - assert_eq!(parsed["received_from"], json!("2026-07-29T21:00:00+00:00")); - assert_eq!(parsed["pull_request"], json!(null)); - } - - /// `render` with colors forced on. `Theme::detect` reports - /// disabled until the CLI entry point records a `--color` choice, - /// which no test does, so the styled branch needs driving - /// explicitly — and what it emits is the whole point of this - /// change. - fn render_in_color(events: Vec) -> String { - let events: Vec = events.into_iter().map(Event::from_raw).collect(); - let window = Window::last(DEFAULT_SINCE, at("2026-07-30T21:00:00Z")).unwrap(); - let mut out: Vec = Vec::new(); - render( - &mut out, - &Theme::new(true), - &Timeline { - scope: "owner/repo", - window: &window, - events: &events, - truncated: false, - show_pull_request: true, - }, - ) - .unwrap(); - String::from_utf8(out).unwrap() - } - - /// The SGR sequence `style` opens with, as it lands in the output. - fn code(style: Style) -> String { - format!("{style}") - } - - #[test] - fn success_and_failure_do_not_look_alike() { - // The complaint: everything grey, so the one word that - // matters reads like all the others. - let theme = Theme::new(true); - let stdout = render_in_color(vec![ - json!({ - "id": 2, - "type": "action.merge", - "received_at": "2026-07-30T12:00:00Z", - "pull_request": 1740, - "outcome": "success", - }), - json!({ - "id": 1, - "type": "action.queue.checks_end", - "received_at": "2026-07-30T11:00:00Z", - "pull_request": 1740, - "outcome": "failure", - "metadata": {"aborted": false}, - }), - ]); - assert!( - stdout.contains(&format!("{}failure", code(theme.red))), - "got: {stdout:?}", - ); - assert!( - stdout.contains(&format!("{}success", code(theme.green))), - "got: {stdout:?}", - ); - } - - #[test] - fn the_queue_verdicts_carry_their_own_colors() { - // The distinction the timeline exists to make readable: an - // abort left the PR queued (yellow), a dequeue ejected it - // (red, as `queue show` paints it), a merge is the good end. - let theme = Theme::new(true); - let stdout = render_in_color(vec![ - json!({ - "id": 3, - "type": "action.queue.leave", - "received_at": "2026-07-30T13:00:00Z", - "pull_request": 1740, - "metadata": {"merged": true}, - }), - json!({ - "id": 2, - "type": "action.queue.leave", - "received_at": "2026-07-30T12:00:00Z", - "pull_request": 1741, - "metadata": {"merged": false, "dequeue_code": "CHECKS_FAILED"}, - }), - json!({ - "id": 1, - "type": "action.queue.checks_end", - "received_at": "2026-07-30T11:00:00Z", - "pull_request": 1742, - "metadata": {"aborted": true, "abort_code": "PR_AHEAD_DEQUEUED"}, - }), - ]); - assert!( - stdout.contains(&format!("{}PR_AHEAD_DEQUEUED", code(theme.yellow))), - "got: {stdout:?}", - ); - assert!( - stdout.contains(&format!("{}CHECKS_FAILED", code(theme.red))), - "got: {stdout:?}", - ); - assert!( - stdout.contains(&format!("{}merged", code(theme.green))), - "got: {stdout:?}", - ); - } - - #[test] - fn the_pull_request_column_reads_as_an_identifier() { - // Cyan `#N` is what `queue status` already uses for a pull - // request; the timeline must not invent a second convention. - let theme = Theme::new(true); - let stdout = render_in_color(vec![json!({ - "id": 1, - "type": "action.queue.enter", - "received_at": "2026-07-30T12:00:00Z", - "pull_request": 1740, - "metadata": {"queue_name": "default"}, - })]); - assert!( - stdout.contains(&format!("{}#1740", code(theme.cyan))), - "got: {stdout:?}", - ); - } - - #[test] - fn a_neutral_outcome_yields_to_the_trigger() { - // `neutral` is the API saying "no verdict here". Printing it - // is what turned the repo-wide view into a column of the same - // word; the trigger at least says who did it. - let event = Event::from_raw(json!({ - "type": "action.label", - "outcome": "neutral", - "trigger": "Rule: automatic label", - })); - let cell = summary(&event).unwrap(); - assert_eq!(cell.text, "Rule: automatic label"); - assert_eq!(cell.tone, Tone::Muted); - - // Nothing to fall back to: no summary beats a bare `neutral`. - let bare = Event::from_raw(json!({"type": "action.label", "outcome": "neutral"})); - assert!(summary(&bare).is_none()); - } - - #[test] - fn the_queue_state_events_report_the_queue_they_moved() { - // 93% of a busy repository's default page: no pull request, - // `neutral` outcome, and the same trigger on every one. The - // depth is the only thing that changes between them, so it is - // the only thing worth printing. - let change = Event::from_raw(json!({ - "type": "action.queue.change", - "outcome": "neutral", - "trigger": "merge queue internal", - "metadata": {"queue_name": "default", "size": 3}, - })); - assert_eq!(summary(&change).unwrap().text, "default · 3 PRs"); - - let checks = Event::from_raw(json!({ - "type": "action.queue.checks.change", - "outcome": "neutral", - "trigger": "merge queue internal", - "metadata": {"queue_name": "default", "running_checks": 1}, - })); - assert_eq!(summary(&checks).unwrap().text, "default · 1 check"); - } - - #[test] - fn a_label_event_names_the_labels() { - // "Rule: label on unresolved" is the same string on every - // line the rule ever fired; the label is what differs. - let event = Event::from_raw(json!({ - "type": "action.label", - "outcome": "neutral", - "trigger": "Rule: label on unresolved", - "metadata": {"added": ["conflict"], "removed": ["review threads unresolved"]}, - })); - assert_eq!( - summary(&event).unwrap().text, - "+conflict -review threads unresolved", - ); - } - - #[test] - fn a_review_request_names_the_reviewers() { - // The API sends the empty half of the pair, and an empty list - // must not print as a stray separator. - let event = Event::from_raw(json!({ - "type": "action.request_reviews", - "outcome": "neutral", - "metadata": {"reviewers": [], "team_reviewers": ["devs"]}, - })); - assert_eq!(summary(&event).unwrap().text, "@devs"); - } - - #[test] - fn a_comment_event_quotes_its_first_line() { - let event = Event::from_raw(json!({ - "type": "action.comment", - "outcome": "neutral", - "metadata": {"message": "@kozlek this pull request is now in conflict 😩\n\nsecond line"}, - })); - assert_eq!( - summary(&event).unwrap().text, - "@kozlek this pull request is now in conflict 😩", - ); - } - - #[test] - fn a_long_comment_is_clipped_on_a_character_boundary() { - // Real comment bodies carry emoji: clipping by bytes would - // panic mid-codepoint, and a timeline column has to end - // somewhere. - let event = Event::from_raw(json!({ - "type": "action.comment", - "metadata": {"message": "😩".repeat(200)}, - })); - let text = summary(&event).unwrap().text; - assert_eq!(text.chars().count(), 72); - assert!(text.ends_with('…'), "got: {text}"); - } - - #[test] - fn a_known_type_missing_its_metadata_still_falls_back() { - // An older engine, or a payload shape that moved: a taught - // type with nothing to read must degrade to what every event - // has, not print an empty column. - let event = Event::from_raw(json!({ - "type": "action.label", - "outcome": "neutral", - "trigger": "Rule: automatic label", - })); - assert_eq!(summary(&event).unwrap().text, "Rule: automatic label"); - } - - #[test] - fn an_unknown_outcome_still_prints() { - // A verdict word from a newer engine renders, uncolored — - // this CLI does not get to claim it knows what it means. - let event = Event::from_raw(json!({"type": "action.new", "outcome": "quarantined"})); - let cell = summary(&event).unwrap(); - assert_eq!(cell.text, "quarantined"); - assert_eq!(cell.tone, Tone::Muted); - } - - #[test] - fn parse_since_reads_the_documented_units() { - assert_eq!(parse_since("45s").unwrap(), TimeDelta::seconds(45)); - assert_eq!(parse_since("30m").unwrap(), TimeDelta::minutes(30)); - assert_eq!(parse_since("12h").unwrap(), TimeDelta::hours(12)); - assert_eq!(parse_since("7d").unwrap(), TimeDelta::days(7)); - assert_eq!(parse_since("2w").unwrap(), TimeDelta::weeks(2)); - } - - #[test] - fn parse_since_rejects_garbage_with_the_format_in_the_message() { - for bad in ["", "7", "d", "-7d", "0d", "7x", "1.5h"] { - let err = parse_since(bad).unwrap_err(); - assert!( - err.contains("7d") || err.contains("30m"), - "the error must show the expected shape, got: {err}", - ); - } - } - - #[test] - fn parse_since_rejects_a_span_past_the_cap_and_names_the_fix() { - let err = parse_since("94d").unwrap_err(); - assert!(err.contains("90d"), "got: {err}"); - assert!(parse_since("93d").is_ok()); - let err = parse_since("14w").unwrap_err(); - assert!(err.contains("90d"), "got: {err}"); - } -} diff --git a/crates/mergify-events/src/queue_leave.rs b/crates/mergify-events/src/queue_leave.rs deleted file mode 100644 index 3e05d657..00000000 --- a/crates/mergify-events/src/queue_leave.rs +++ /dev/null @@ -1,717 +0,0 @@ -//! The queue-leave explain layer: decode a pull request's last -//! `action.queue.leave` event and render *why* it left the merge -//! queue — the sentence, the per-reason hint, the failing checks with -//! their URLs, and the next step. -//! -//! The generic [`fetch`](crate::fetch) client owns the window, -//! pagination and ordering traps; two are queue-specific and live -//! here: -//! -//! - **The event type.** `action.queue.leave` is emitted only when -//! the PR actually leaves. The sibling `abort_code` values that -//! ride on `action.queue.checks_end` (`PR_AHEAD_DEQUEUED`, -//! `MERGE_QUEUE_RESET`, `CHECKS_RETRIED`, …) interrupt the *checks* -//! while the PR stays queued; filtering on the event type is what -//! keeps them out of this diagnosis. Reporting one as a dequeue -//! would tell an agent to requeue a PR that never left. -//! - **`metadata.merged`.** A merge is also an `action.queue.leave`. -//! It is the boolean, not the `dequeue_code`, that separates "left -//! by merging" from "was dequeued". -//! -//! Everything user-facing (the sentence, its per-reason hint, the -//! failing check names and URLs) is rendered from what the API -//! returns. The CLI keeps no local copy of the ~32 `dequeue_code` -//! strings: a stale copy would drift, and an unknown code must render -//! rather than crash. - -use std::io::Write; - -use chrono::DateTime; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::http::Client; -use mergify_tui::Theme; -use mergify_tui::relative_time; -use serde::Deserialize; - -use crate::client::Query; -use crate::event::Event; -use crate::window::RETENTION_DAYS; -use crate::window::Window; - -const LEAVE_EVENT_TYPE: &str = "action.queue.leave"; - -/// How many lines of the API's `reason` prose to print without -/// `--verbose`. A `PR_DEQUEUED` reason embeds the full unmet -/// condition tree and runs to 60+ lines on a real repository, which -/// buries the headline and the failing checks under it. The first -/// lines carry the sentence and its hint; the tree that follows is -/// what `-v` is for — the same compact/verbose split the in-queue -/// conditions section already uses. -const REASON_COMPACT_LINES: usize = 12; - -/// The pull request's last `action.queue.leave`, both as the raw API -/// event (what `--json` republishes, so unknown fields survive) and -/// as the decoded view the human renderer reads. -pub struct LastLeave { - pub raw: serde_json::Value, - pub event: LeaveEvent, -} - -impl LastLeave { - /// Decode the leave-specific view out of a fetched [`Event`]. - /// - /// # Errors - /// - /// A payload whose fields have the wrong shape fails with a - /// wrapped decode error; callers treat that like an unreadable - /// log, never as "no dequeue found". - pub fn from_event(event: Event) -> Result { - let raw = event.raw; - let event: LeaveEvent = serde_json::from_value(raw.clone()) - .map_err(|e| CliError::wrap("decode queue leave event", e))?; - Ok(Self { raw, event }) - } - - /// Whether the PR *left the queue without merging* — the answer - /// `queued: false` alone could never give. - #[must_use] - pub const fn dequeued(&self) -> bool { - !self.event.metadata.merged - } - - /// The PR head this diagnosis describes, for a caller that needs - /// to check it still is the PR's head — see - /// [`LeaveMetadata::pull_request_head_sha`]. - #[must_use] - pub fn head_sha(&self) -> Option<&str> { - non_empty(self.event.metadata.pull_request_head_sha.as_deref()) - } -} - -/// Decoded `action.queue.leave` event. Every field is optional: a -/// diagnosis rendered from a payload missing one field is worth more -/// than a hard error, and new API fields must never break the -/// fallback. -#[derive(Deserialize, Default)] -pub struct LeaveEvent { - #[serde(default)] - pub received_at: Option, - #[serde(default)] - pub trigger: Option, - #[serde(default)] - pub metadata: LeaveMetadata, -} - -#[derive(Deserialize, Default)] -pub struct LeaveMetadata { - #[serde(default)] - pub merged: bool, - #[serde(default)] - pub dequeue_code: Option, - #[serde(default)] - pub reason: Option, - #[serde(default)] - pub queue_name: Option, - #[serde(default)] - pub queued_at: Option, - /// The PR head the queue was testing when it left. Load-bearing - /// for anyone acting on this diagnosis: the `unsuccessful_checks` - /// below belong to *this* commit, and a push since then (the very - /// thing `PULL_REQUEST_UPDATED` and `DRAFT_PULL_REQUEST_CHANGED` - /// report) leaves them describing a commit the PR no longer has. - /// Comparing it against the current head is what tells a live - /// failure apart from one already superseded. - #[serde(default)] - pub pull_request_head_sha: Option, - #[serde(default)] - pub unsuccessful_checks: Vec, -} - -#[derive(Deserialize, Default)] -pub struct LeaveCheck { - #[serde(default)] - pub name: String, - #[serde(default)] - pub state: Option, - /// The check's own page (a workflow-job URL for GitHub Actions). - /// `queue show`'s in-queue checks section has never carried a URL, - /// which is why "where is the failure?" needed a second tool. - #[serde(default)] - pub details_url: Option, - #[serde(default)] - pub url: Option, -} - -impl LeaveCheck { - /// The best link to the check. `details_url` is the check run's - /// own page; `url` is the fallback the API sends when the - /// integration provides no details link. - fn link(&self) -> Option<&str> { - self.details_url - .as_deref() - .or(self.url.as_deref()) - .filter(|s| !s.is_empty()) - } -} - -/// Fetch the pull request's last exit from the merge queue, or `None` -/// when the activity log holds no `action.queue.leave` for it in the -/// retained window (the genuine never-queued case, and the case of a -/// PR number that never existed). -/// -/// # Errors -/// -/// Propagates the API failure. Callers treat that as "could not -/// determine", not as a command failure: `queue show` must keep -/// answering (and exiting 0) for a token that cannot read the -/// repository's activity log. -pub async fn fetch_last( - client: &Client, - repository: &str, - pr_number: u64, - now: DateTime, -) -> Result, CliError> { - let query = Query { - pull_request: Some(pr_number), - event_types: vec![LEAVE_EVENT_TYPE.to_string()], - window: Window::retained(now), - // Newest-first ordering makes the single fetched event the - // PR's latest exit. - limit: Some(1), - }; - let log = crate::client::fetch(client, repository, &query).await?; - match log.events.into_iter().next() { - Some(event) => LastLeave::from_event(event).map(Some), - None => Ok(None), - } -} - -/// Render the diagnosis for a PR that left the merge queue: a -/// headline naming *what* happened and when, an aligned facts block, -/// the API's own reason prose, the failing checks with their URLs, -/// and the next step. -pub fn render( - w: &mut dyn Write, - theme: &Theme, - leave: &LastLeave, - pr_number: u64, - now: DateTime, - verbose: bool, -) -> std::io::Result<()> { - let meta = &leave.event.metadata; - let when = leave - .event - .received_at - .as_deref() - .map(|ts| relative_time(ts, now, false)) - .filter(|rel| !rel.is_empty()); - - let headline = if meta.merged { - "was merged by the merge queue" - } else { - "was dequeued" - }; - let style = if meta.merged { theme.green } else { theme.red }; - write!( - w, - "{S}PR #{pr_number} {headline}{R}", - S = style, - R = theme.reset, - )?; - match &when { - Some(rel) => writeln!(w, " {D}{rel}{R}", D = theme.dim, R = theme.reset)?, - None => writeln!(w)?, - } - writeln!(w)?; - - // Only a dequeue carries a meaningful code — a merge's code is - // always the tautological PR_MERGED. - if !meta.merged - && let Some(code) = non_empty(meta.dequeue_code.as_deref()) - { - writeln!( - w, - " Dequeue code: {B}{code}{R}", - B = theme.bold, - R = theme.reset - )?; - } - if let Some(queue) = non_empty(meta.queue_name.as_deref()) { - writeln!(w, " Queue: {queue}")?; - } - if let Some(rel) = meta - .queued_at - .as_deref() - .map(|ts| relative_time(ts, now, false)) - .filter(|rel| !rel.is_empty()) - { - writeln!(w, " Queued at: {rel}")?; - } - if let Some(trigger) = non_empty(leave.event.trigger.as_deref()) { - writeln!(w, " Trigger: {trigger}")?; - } - // Printed for a merge too: it names the commit that landed. On a - // dequeue it is the caveat on everything below — the reason and - // the check URLs describe this commit, not necessarily the one the - // PR carries now. - if let Some(sha) = leave.head_sha() { - writeln!(w, " Head SHA: {sha}", sha = short_sha(sha))?; - } - - if let Some(reason) = non_empty(meta.reason.as_deref()) { - writeln!(w)?; - print_reason(w, theme, reason, verbose)?; - } - - if !meta.unsuccessful_checks.is_empty() { - writeln!(w)?; - print_failing_checks(w, theme, &meta.unsuccessful_checks)?; - } - - if !meta.merged { - writeln!(w)?; - writeln!( - w, - " {D}Fix the cause above, then comment `@mergifyio queue` on the pull request.{R}", - D = theme.dim, - R = theme.reset, - )?; - } - Ok(()) -} - -/// Print the API's own explanation. The engine owns this prose and -/// the per-reason hint embedded in it; it arrives as multi-line -/// markdown, so indent every line rather than squeezing it into the -/// aligned facts block, and cap it at [`REASON_COMPACT_LINES`] unless -/// `verbose`. -fn print_reason( - w: &mut dyn Write, - theme: &Theme, - reason: &str, - verbose: bool, -) -> std::io::Result<()> { - let lines: Vec<&str> = reason.lines().collect(); - let shown = if verbose { - lines.len() - } else { - lines.len().min(REASON_COMPACT_LINES) - }; - for line in &lines[..shown] { - if line.is_empty() { - writeln!(w)?; - } else { - writeln!(w, " {line}")?; - } - } - let hidden = lines.len() - shown; - if hidden > 0 { - writeln!( - w, - " {D}… {hidden} more lines (-v for the full reason){R}", - D = theme.dim, - R = theme.reset, - )?; - } - Ok(()) -} - -/// Print the checks that were not green when the PR left, each with -/// its URL. `queue show`'s in-queue checks section renders names and -/// states but no links, which is why "where is the failure?" used to -/// need a second tool. -fn print_failing_checks( - w: &mut dyn Write, - theme: &Theme, - checks: &[LeaveCheck], -) -> std::io::Result<()> { - writeln!(w, " Failing checks:")?; - for check in checks { - write!( - w, - " {S}✗{R} {name}", - S = theme.red, - R = theme.reset, - name = check.name, - )?; - match non_empty(check.state.as_deref()) { - Some(state) => writeln!(w, " {D}{state}{R}", D = theme.dim, R = theme.reset)?, - None => writeln!(w)?, - } - if let Some(link) = check.link() { - writeln!(w, " {D}{link}{R}", D = theme.dim, R = theme.reset)?; - } - } - Ok(()) -} - -/// Render the "we looked and found nothing" case. Distinct from the -/// bare notice line because "no queue activity in the retained -/// window" is an answer, whereas silence is not. -pub fn render_no_activity(w: &mut dyn Write, theme: &Theme) -> std::io::Result<()> { - writeln!( - w, - " {D}No merge-queue activity in the last {RETENTION_DAYS} days.{R}", - D = theme.dim, - R = theme.reset, - ) -} - -fn non_empty(value: Option<&str>) -> Option<&str> { - value.filter(|s| !s.is_empty()) -} - -/// Abbreviate a commit SHA for display, the way git and GitHub do. -/// `--json` keeps the full value — a caller comparing it against the -/// PR head needs the whole thing. -fn short_sha(sha: &str) -> &str { - sha.char_indices() - .nth(7) - .map_or(sha, |(byte_index, _)| &sha[..byte_index]) -} - -#[cfg(test)] -mod tests { - use mergify_core::http::ApiFlavor; - use serde_json::json; - use url::Url; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - fn at(iso: &str) -> DateTime { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - } - - fn client(server: &MockServer) -> Client { - Client::new(Url::parse(&server.uri()).unwrap(), "t", ApiFlavor::Mergify).unwrap() - } - - /// Shape copied from a real `Mergifyio/mergify-cli` event. - fn checks_failed_event() -> serde_json::Value { - json!({ - "id": 77_235_715, - "received_at": "2026-07-20T23:25:11.263987Z", - "trigger": "merge queue internal", - "repository": "owner/repo", - "pull_request": 1700, - "base_ref": "main", - "outcome": "failure", - "type": "action.queue.leave", - "metadata": { - "reason": "The merge conditions cannot be satisfied due to failing checks\n\n- `ci-gate`", - "merged": false, - "queue_name": "default", - "branch": "main", - "queued_at": "2026-07-20T23:11:42.944734Z", - "pull_request_head_sha": "31b4a485b8ce6f2c1d0e9a7b4c5d6e7f80910111", - "dequeue_code": "CHECKS_FAILED", - "checks": [], - "unsuccessful_checks": [{ - "name": "ci-gate", - "description": "", - "url": "https://github.com/owner/repo/actions/runs/1/job/2", - "details_url": "https://github.com/owner/repo/actions/runs/1/job/2", - "state": "failure", - "avatar_url": null, - }], - }, - }) - } - - async fn arrange(server: &MockServer, events: Vec) { - let size = events.len(); - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "size": size, - "per_page": 1, - "events": events, - }))) - .expect(1) - .mount(server) - .await; - } - - #[tokio::test] - async fn fetch_last_sends_the_queue_leave_query_with_a_90_day_window() { - // Every one of these parameters is a documented trap: the - // default 1-day window hides older dequeues, the event type - // keeps `checks_end` abort codes (which do NOT dequeue) out, - // newest-first + a single-event page selects the latest exit. - let server = MockServer::start().await; - arrange(&server, vec![checks_failed_event()]).await; - - let now = at("2026-07-30T00:00:00Z"); - let got = fetch_last(&client(&server), "owner/repo", 1700, now) - .await - .unwrap(); - assert!(got.is_some()); - - let received = server.received_requests().await.unwrap(); - let query: Vec<(String, String)> = received[0] - .url - .query_pairs() - .map(|(k, v)| (k.into_owned(), v.into_owned())) - .collect(); - assert!( - query.contains(&("pull_request".into(), "1700".into())), - "got: {query:?}", - ); - assert!( - query.contains(&("event_type".into(), "action.queue.leave".into())), - "got: {query:?}", - ); - assert!( - query.contains(&("per_page".into(), "1".into())), - "got: {query:?}", - ); - let from = query - .iter() - .find(|(k, _)| k == "received_from") - .map(|(_, v)| v.clone()) - .expect("received_from must be explicit, not left to the 1-day default"); - assert_eq!(at(&from), at("2026-05-01T00:00:00Z")); - } - - #[tokio::test] - async fn fetch_last_returns_none_when_the_log_holds_no_leave_event() { - let server = MockServer::start().await; - arrange(&server, vec![]).await; - - let got = fetch_last( - &client(&server), - "owner/repo", - 999, - at("2026-07-30T00:00:00Z"), - ) - .await - .unwrap(); - assert!(got.is_none()); - } - - #[tokio::test] - async fn fetch_last_tolerates_an_event_missing_every_optional_field() { - // A payload the CLI has never seen must still produce a - // diagnosis rather than a decode error. - let server = MockServer::start().await; - arrange(&server, vec![json!({"type": "action.queue.leave"})]).await; - - let got = fetch_last( - &client(&server), - "owner/repo", - 1, - at("2026-07-30T00:00:00Z"), - ) - .await - .unwrap() - .expect("an event with only a type is still an event"); - assert!(got.dequeued(), "merged defaults to false"); - assert!(got.event.metadata.dequeue_code.is_none()); - } - - #[tokio::test] - async fn dequeued_is_false_for_a_merge() { - let server = MockServer::start().await; - let mut event = checks_failed_event(); - event["metadata"]["merged"] = json!(true); - event["metadata"]["dequeue_code"] = json!("PR_MERGED"); - arrange(&server, vec![event]).await; - - let got = fetch_last( - &client(&server), - "owner/repo", - 1700, - at("2026-07-30T00:00:00Z"), - ) - .await - .unwrap() - .unwrap(); - assert!(!got.dequeued(), "a merge is a leave, but not a dequeue"); - } - - fn render_with(leave: &LastLeave, pr_number: u64, verbose: bool) -> String { - let mut buf: Vec = Vec::new(); - render( - &mut buf, - &Theme::new(false), - leave, - pr_number, - at("2026-07-30T00:00:00Z"), - verbose, - ) - .unwrap(); - String::from_utf8(buf).unwrap() - } - - fn render_to_string(leave: &LastLeave, pr_number: u64) -> String { - render_with(leave, pr_number, false) - } - - fn leave_from(raw: serde_json::Value) -> LastLeave { - LastLeave::from_event(Event::from_raw(raw)).unwrap() - } - - #[test] - fn render_dequeue_names_the_code_reason_checks_and_next_step() { - let rendered = render_to_string(&leave_from(checks_failed_event()), 1700); - assert!( - rendered.contains("PR #1700 was dequeued"), - "got: {rendered}" - ); - assert!(rendered.contains("9d ago"), "got: {rendered}"); - assert!(rendered.contains("CHECKS_FAILED"), "got: {rendered}"); - assert!(rendered.contains("default"), "got: {rendered}"); - assert!( - rendered.contains("The merge conditions cannot be satisfied"), - "got: {rendered}", - ); - assert!(rendered.contains("ci-gate"), "got: {rendered}"); - assert!( - rendered.contains("https://github.com/owner/repo/actions/runs/1/job/2"), - "the failing check's URL is the whole point: {rendered}", - ); - assert!(rendered.contains("@mergifyio queue"), "got: {rendered}"); - } - - #[test] - fn render_merge_says_merged_and_offers_no_requeue() { - let mut raw = checks_failed_event(); - raw["metadata"]["merged"] = json!(true); - raw["metadata"]["dequeue_code"] = json!("PR_MERGED"); - raw["metadata"]["reason"] = json!("Pull request #1700 has been merged automatically"); - raw["metadata"]["unsuccessful_checks"] = json!([]); - - let rendered = render_to_string(&leave_from(raw), 1700); - assert!( - rendered.contains("PR #1700 was merged by the merge queue"), - "got: {rendered}", - ); - assert!( - !rendered.contains("Dequeue code"), - "PR_MERGED is tautological, not a dequeue reason: {rendered}", - ); - assert!( - !rendered.contains("requeue"), - "nothing to requeue after a merge: {rendered}", - ); - } - - #[test] - fn head_sha_is_the_commit_the_diagnosis_describes() { - // The checks in this event belong to that commit. A consumer - // comparing it against the PR's current head is what keeps a - // dequeue already superseded by a push from being reported as - // a live failure — so the full SHA must survive the decode. - let leave = leave_from(checks_failed_event()); - assert_eq!( - leave.head_sha(), - Some("31b4a485b8ce6f2c1d0e9a7b4c5d6e7f80910111"), - ); - } - - #[test] - fn head_sha_is_absent_rather_than_empty_when_the_event_omits_it() { - let mut raw = checks_failed_event(); - raw["metadata"]["pull_request_head_sha"] = json!(""); - assert!(leave_from(raw).head_sha().is_none()); - - let mut raw = checks_failed_event(); - raw["metadata"] - .as_object_mut() - .unwrap() - .remove("pull_request_head_sha"); - assert!(leave_from(raw).head_sha().is_none()); - } - - #[test] - fn render_abbreviates_the_head_sha() { - let rendered = render_to_string(&leave_from(checks_failed_event()), 1700); - assert!( - rendered.contains("Head SHA: 31b4a48"), - "got: {rendered}", - ); - } - - #[test] - fn render_omits_the_head_sha_row_when_the_event_has_none() { - let mut raw = checks_failed_event(); - raw["metadata"]["pull_request_head_sha"] = json!(null); - let rendered = render_to_string(&leave_from(raw), 1700); - assert!(!rendered.contains("Head SHA"), "got: {rendered}"); - } - - #[test] - fn render_survives_an_unknown_dequeue_code() { - // New engine codes must render verbatim, matching the - // survive-unknown-values stance `check_state_glyph` takes. - let mut raw = checks_failed_event(); - raw["metadata"]["dequeue_code"] = json!("SOMETHING_NEW_IN_2027"); - let rendered = render_to_string(&leave_from(raw), 1700); - assert!( - rendered.contains("SOMETHING_NEW_IN_2027"), - "got: {rendered}", - ); - } - - #[test] - fn render_falls_back_to_url_when_details_url_is_absent() { - let mut raw = checks_failed_event(); - raw["metadata"]["unsuccessful_checks"][0]["details_url"] = json!(null); - let rendered = render_to_string(&leave_from(raw), 1700); - assert!( - rendered.contains("https://github.com/owner/repo/actions/runs/1/job/2"), - "got: {rendered}", - ); - } - - #[test] - fn render_truncates_a_long_reason_until_verbose() { - // A real `PR_DEQUEUED` reason embeds the whole unmet - // condition tree — 60+ lines on `Mergifyio/monorepo` — which - // would bury the headline and the failing checks. - let mut raw = checks_failed_event(); - let long: Vec = (0..40).map(|i| format!("line {i}")).collect(); - raw["metadata"]["reason"] = json!(long.join("\n")); - let leave = leave_from(raw); - - let compact = render_with(&leave, 1700, false); - assert!(compact.contains("line 0"), "got: {compact}"); - assert!(compact.contains("line 11"), "got: {compact}"); - assert!(!compact.contains("line 12"), "got: {compact}"); - assert!( - compact.contains("… 28 more lines (-v for the full reason)"), - "truncation must announce itself: {compact}", - ); - // The next step still lands after the truncated reason. - assert!(compact.contains("@mergifyio queue"), "got: {compact}"); - - let verbose = render_with(&leave, 1700, true); - assert!(verbose.contains("line 39"), "got: {verbose}"); - assert!(!verbose.contains("more lines"), "got: {verbose}"); - } - - #[test] - fn render_does_not_announce_truncation_for_a_short_reason() { - let compact = render_to_string(&leave_from(checks_failed_event()), 1700); - assert!(!compact.contains("more lines"), "got: {compact}"); - } - - #[test] - fn render_omits_the_timestamp_when_it_is_unparseable() { - let mut raw = checks_failed_event(); - raw["received_at"] = json!("not-a-date"); - let rendered = render_to_string(&leave_from(raw), 1700); - assert!( - rendered.starts_with("PR #1700 was dequeued\n"), - "a bad timestamp must not leak into the headline: {rendered}", - ); - } -} diff --git a/crates/mergify-events/src/window.rs b/crates/mergify-events/src/window.rs deleted file mode 100644 index bd67312f..00000000 --- a/crates/mergify-events/src/window.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! Explicit time range for an activity-log query. -//! -//! `/logs` has two window traps and this type is where both die: -//! the silent `received_to - 1 day` default (a [`Window`] always -//! carries both bounds, so callers cannot forget to send them) and -//! the 422 on a span over 93 days (construction refuses it, so the -//! raw HTTP error is unreachable). - -use chrono::DateTime; -use chrono::TimeDelta; -use chrono::Utc; - -/// How many days of events the API retains. A query reaching further -/// back returns nothing extra — [`Window::retained`] is the widest -/// window worth asking for. -pub const RETENTION_DAYS: i64 = 90; - -/// The widest `received_from`/`received_to` span the API accepts; -/// anything wider is rejected with a 422 (`'received_from' and -/// 'received_to' cannot span more than 93 days`). -pub const MAX_SPAN_DAYS: i64 = 93; - -/// An explicit `received_from`/`received_to` range, guaranteed to be -/// positive and within the API's span cap. -#[derive(Copy, Clone, Debug)] -pub struct Window { - from: DateTime, - to: DateTime, -} - -/// Why a [`Window`] could not be constructed. Surfacing this at -/// construction is the whole point: the caller gets a typed, -/// explainable refusal instead of the API's raw 422. -#[derive(thiserror::Error, Debug, PartialEq, Eq)] -pub enum WindowError { - #[error( - "a {requested_days}-day window is wider than the API accepts \ - (over {max} days); the activity log retains {retention} days \ - — use {retention} days or less", - max = MAX_SPAN_DAYS, - retention = RETENTION_DAYS - )] - SpanTooWide { requested_days: i64 }, - - #[error("the window must cover a positive length of time")] - NotPositive, -} - -impl Window { - /// The whole retained history, ending at `now`. This is - /// "everything the API can still answer for", spelled without the - /// caller knowing the retention number. - #[must_use] - pub fn retained(now: DateTime) -> Self { - Self { - from: now - TimeDelta::days(RETENTION_DAYS), - to: now, - } - } - - /// The last `span` of time, ending at `now`. - /// - /// # Errors - /// - /// [`WindowError::SpanTooWide`] when `span` exceeds - /// [`MAX_SPAN_DAYS`] (the API would 422); - /// [`WindowError::NotPositive`] when it is zero or negative. - pub fn last(span: TimeDelta, now: DateTime) -> Result { - if span <= TimeDelta::zero() { - return Err(WindowError::NotPositive); - } - if span > TimeDelta::days(MAX_SPAN_DAYS) { - return Err(WindowError::SpanTooWide { - requested_days: ceil_days(span), - }); - } - Ok(Self { - from: now - span, - to: now, - }) - } - - #[must_use] - pub const fn from(&self) -> DateTime { - self.from - } - - #[must_use] - pub const fn to(&self) -> DateTime { - self.to - } -} - -/// `span` in days, rounded **up**. Truncating (`num_days`) makes a -/// 93-day-and-one-hour ask report itself as "a 93-day window is wider -/// than the API accepts (over 93 days)" — a refusal that contradicts -/// its own reason. A partial day is a day the caller asked for. -fn ceil_days(span: TimeDelta) -> i64 { - let whole = span.num_days(); - if span > TimeDelta::days(whole) { - whole + 1 - } else { - whole - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn at(iso: &str) -> DateTime { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - } - - #[test] - fn retained_covers_the_whole_retention() { - let now = at("2026-07-30T00:00:00Z"); - let window = Window::retained(now); - assert_eq!(window.to(), now); - assert_eq!(window.from(), at("2026-05-01T00:00:00Z")); - } - - #[test] - fn last_builds_an_explicit_range_ending_now() { - let now = at("2026-07-30T12:00:00Z"); - let window = Window::last(TimeDelta::hours(24), now).unwrap(); - assert_eq!(window.from(), at("2026-07-29T12:00:00Z")); - assert_eq!(window.to(), now); - } - - #[test] - fn last_refuses_a_span_the_api_would_422_on() { - // The typed replacement for the raw `422: 'received_from' and - // 'received_to' cannot span more than 93 days`. - let now = at("2026-07-30T00:00:00Z"); - let err = Window::last(TimeDelta::days(94), now).unwrap_err(); - assert_eq!(err, WindowError::SpanTooWide { requested_days: 94 }); - // The message must hand the user the fix, not just the refusal. - assert!(err.to_string().contains("retains 90 days"), "got: {err}"); - } - - #[test] - fn a_part_day_over_the_cap_rounds_the_message_up() { - // `--since 2233h` is 93 days and one hour. Reported with a - // truncating day count it reads "a 93-day window is wider than - // the API accepts (over 93 days)". - let now = at("2026-07-30T00:00:00Z"); - let span = TimeDelta::days(MAX_SPAN_DAYS) + TimeDelta::hours(1); - let err = Window::last(span, now).unwrap_err(); - assert_eq!(err, WindowError::SpanTooWide { requested_days: 94 }); - // Sub-second precision counts too: a whole day plus a nanosecond - // is still more than the caller may have. - let sliver = TimeDelta::days(MAX_SPAN_DAYS) + TimeDelta::nanoseconds(1); - assert_eq!( - Window::last(sliver, now).unwrap_err(), - WindowError::SpanTooWide { requested_days: 94 }, - ); - } - - #[test] - fn last_accepts_the_span_cap_itself() { - let now = at("2026-07-30T00:00:00Z"); - assert!(Window::last(TimeDelta::days(MAX_SPAN_DAYS), now).is_ok()); - } - - #[test] - fn last_refuses_an_empty_or_negative_span() { - let now = at("2026-07-30T00:00:00Z"); - assert_eq!( - Window::last(TimeDelta::zero(), now).unwrap_err(), - WindowError::NotPositive, - ); - assert_eq!( - Window::last(TimeDelta::hours(-1), now).unwrap_err(), - WindowError::NotPositive, - ); - } -} diff --git a/crates/mergify-freeze/Cargo.toml b/crates/mergify-freeze/Cargo.toml deleted file mode 100644 index 1bf80caa..00000000 --- a/crates/mergify-freeze/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "mergify-freeze" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify freeze` subcommands." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } -mergify-tui = { path = "../mergify-tui" } -anstyle = { workspace = true } -chrono = { workspace = true } -iana-time-zone = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } - -[dev-dependencies] -mergify-test-support = { path = "../mergify-test-support" } -tokio = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-freeze/src/common.rs b/crates/mergify-freeze/src/common.rs deleted file mode 100644 index 3a006c3a..00000000 --- a/crates/mergify-freeze/src/common.rs +++ /dev/null @@ -1,231 +0,0 @@ -//! Shared helpers across the freeze subcommands. -//! -//! Four responsibilities: -//! -//! - [`ScheduledFreeze`] — the wire format shared by every endpoint. -//! - [`write_freeze`] — the per-freeze human block that -//! `create` and `update` emit on success. Format matches Python's -//! `_print_freeze` so the smoke tests parse the same way against -//! either implementation. -//! - [`parse_naive_datetime`] — accept the ISO-8601 datetime -//! strings users give to `--start` / `--end`, rejecting anything -//! Python's `datetime.fromisoformat` would reject. -//! - [`detect_local_timezone`] — wrapper around `iana-time-zone` -//! that produces the user-facing error Python raises when -//! `tzlocal.get_localzone_name()` returns nothing. - -use std::io::Write; - -use chrono::NaiveDateTime; -use mergify_core::CliError; -use serde::Deserialize; - -/// Mergify's `/scheduled_freeze` resource — used as the response -/// shape on every freeze endpoint. Every field is optional so the -/// CLI can render whatever the server actually returned without -/// aborting on a missing key. -#[derive(Deserialize, Debug, Clone)] -pub struct ScheduledFreeze { - #[serde(default)] - pub id: Option, - #[serde(default)] - pub reason: Option, - #[serde(default)] - pub start: Option, - #[serde(default)] - pub end: Option, - #[serde(default)] - pub timezone: Option, - #[serde(default)] - pub matching_conditions: Vec, - #[serde(default)] - pub exclude_conditions: Vec, -} - -/// `--start` / `--end` payload encoder: the Python CLI feeds an -/// `isoformat()` string to the API, which is exactly the input -/// string we parsed (no offset). [`Self::iso`] reproduces that -/// shape — seconds precision, no offset — so the round-trip -/// matches what the Mergify server has historically accepted. -#[derive(Debug, Clone, Copy)] -pub struct NaiveDateTimeWire<'a>(pub &'a NaiveDateTime); - -impl NaiveDateTimeWire<'_> { - #[must_use] - pub fn iso(self) -> String { - // `%Y-%m-%dT%H:%M:%S` matches Python's `datetime.isoformat()` - // for a naive datetime with no microseconds. - self.0.format("%Y-%m-%dT%H:%M:%S").to_string() - } -} - -/// Parse a user-supplied `--start` / `--end` value as a naive -/// datetime. Accepts the same handful of ISO-8601 shapes -/// `datetime.fromisoformat` accepts: with seconds, with optional -/// fractional seconds, and (best-effort) with a `Z` / offset suffix. -/// Returns a [`CliError::Configuration`] on parse failure so the -/// binary exits with the right code (8) and an obvious message. -pub fn parse_naive_datetime(value: &str) -> Result { - // The order matters: try the most specific patterns first so a - // string with fractional seconds isn't lossily matched by the - // seconds-only parser. - for fmt in [ - "%Y-%m-%dT%H:%M:%S%.f", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S%.f", - "%Y-%m-%d %H:%M:%S", - ] { - if let Ok(dt) = NaiveDateTime::parse_from_str(value, fmt) { - return Ok(dt); - } - } - // RFC-3339 / Z-terminated strings: parse with offset and drop it. - // The Mergify API treats `start` / `end` as naive in the freeze's - // own timezone, so an offset on the input is misleading — but - // accepting the shape and ignoring the offset matches Python's - // `datetime.fromisoformat` (which also strips the offset into a - // naive value before we serialize it back out). - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value) { - return Ok(dt.naive_local()); - } - Err(CliError::Configuration(format!( - "invalid datetime format: {value:?}. \ - Use ISO 8601 format (e.g. 2024-06-19T08:00:00)" - ))) -} - -/// Detect the system's IANA timezone (e.g. `Europe/Paris`). Used -/// as the default for `--timezone` on `freeze create` when the -/// user doesn't pass one — matches Python's -/// `tzlocal.get_localzone_name()` call. Returns a -/// [`CliError::Configuration`] when detection fails so the user -/// gets a clear "pass `--timezone` explicitly" message instead of -/// a panic. -pub fn detect_local_timezone() -> Result { - iana_time_zone::get_timezone().map_err(|_| { - CliError::Configuration( - "Could not detect system timezone. Please specify --timezone explicitly.".to_string(), - ) - }) -} - -/// Emit the per-freeze human block. Format mirrors Python's -/// `_print_freeze`: each label padded to the same column, missing -/// values rendered as `-`. Writes to the caller's -/// [`std::io::Write`] sink so `create` and `update` can stitch the -/// "Freeze … successfully:" banner and the body into a single -/// [`Output::emit`] call. -pub fn write_freeze(w: &mut dyn Write, freeze: &ScheduledFreeze) -> std::io::Result<()> { - let timezone = freeze.timezone.as_deref().unwrap_or(""); - writeln!(w, " ID: {}", freeze.id.as_deref().unwrap_or("-"))?; - writeln!( - w, - " Reason: {}", - freeze.reason.as_deref().unwrap_or("-"), - )?; - writeln!( - w, - " Start: {}", - format_datetime(freeze.start.as_deref(), timezone), - )?; - writeln!( - w, - " End: {}", - format_datetime(freeze.end.as_deref(), timezone), - )?; - let conditions = freeze.matching_conditions.join(", "); - writeln!(w, " Conditions: {conditions}")?; - if !freeze.exclude_conditions.is_empty() { - let excludes = freeze.exclude_conditions.join(", "); - writeln!(w, " Exclude: {excludes}")?; - } - Ok(()) -} - -/// Render an ISO timestamp + IANA timezone tuple the way Python -/// formats them in the `_print_freeze` output: `" ()"` -/// when both are present, `"-"` when the value is missing. -#[must_use] -pub fn format_datetime(value: Option<&str>, timezone: &str) -> String { - match value.filter(|s| !s.is_empty()) { - None => "-".to_string(), - Some(v) => format!("{v} ({timezone})"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_naive_datetime_basic_iso() { - let dt = parse_naive_datetime("2026-05-19T10:30:00").unwrap(); - assert_eq!( - dt.format("%Y-%m-%d %H:%M:%S").to_string(), - "2026-05-19 10:30:00" - ); - } - - #[test] - fn parse_naive_datetime_with_fractional_seconds() { - let dt = parse_naive_datetime("2026-05-19T10:30:00.123456").unwrap(); - assert_eq!( - dt.format("%Y-%m-%d %H:%M:%S").to_string(), - "2026-05-19 10:30:00" - ); - } - - #[test] - fn parse_naive_datetime_space_separator() { - // Python's `fromisoformat` accepts a space between date and - // time as a relaxed alternative to `T`. Mirror that. - let dt = parse_naive_datetime("2026-05-19 10:30:00").unwrap(); - assert_eq!( - dt.format("%Y-%m-%d %H:%M:%S").to_string(), - "2026-05-19 10:30:00" - ); - } - - #[test] - fn parse_naive_datetime_rfc3339_drops_offset() { - // The Mergify API treats `start` as naive in the freeze's - // own timezone — accepting an offset is for Python parity, - // but the offset is dropped so the round-trip keeps the - // wall-clock value the user typed. - let dt = parse_naive_datetime("2026-05-19T10:30:00Z").unwrap(); - assert_eq!( - dt.format("%Y-%m-%d %H:%M:%S").to_string(), - "2026-05-19 10:30:00" - ); - } - - #[test] - fn parse_naive_datetime_rejects_garbage() { - let err = parse_naive_datetime("not a date").unwrap_err(); - assert!(matches!(err, CliError::Configuration(_))); - assert!(err.to_string().contains("invalid datetime format")); - } - - #[test] - fn naive_wire_round_trip_drops_microseconds() { - // Match Python's `isoformat()` on a microsecond-bearing dt: - // we still emit seconds-only because the API expects that - // shape. - let dt = parse_naive_datetime("2026-05-19T10:30:00.123456").unwrap(); - assert_eq!(NaiveDateTimeWire(&dt).iso(), "2026-05-19T10:30:00"); - } - - #[test] - fn format_datetime_missing_renders_dash() { - assert_eq!(format_datetime(None, "UTC"), "-"); - assert_eq!(format_datetime(Some(""), "UTC"), "-"); - } - - #[test] - fn format_datetime_appends_timezone() { - assert_eq!( - format_datetime(Some("2026-01-01T10:00:00"), "Europe/Paris"), - "2026-01-01T10:00:00 (Europe/Paris)", - ); - } -} diff --git a/crates/mergify-freeze/src/create.rs b/crates/mergify-freeze/src/create.rs deleted file mode 100644 index 083682ee..00000000 --- a/crates/mergify-freeze/src/create.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! `mergify freeze create` — schedule a new freeze. -//! -//! `POST /v1/repos//scheduled_freeze`. Payload mirrors -//! Python's `CreateScheduledFreezePayload` exactly: `reason`, -//! `start`, `end`, `timezone` always present (with `null` for an -//! open-ended emergency freeze), `matching_conditions` and -//! `exclude_conditions` only included when non-empty. On success -//! the server echoes the freeze body, which we render through the -//! shared [`write_freeze`](crate::common::write_freeze) helper. - -use std::io::Write; - -use chrono::NaiveDateTime; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use serde::Serialize; - -use crate::common::NaiveDateTimeWire; -use crate::common::ScheduledFreeze; -use crate::common::detect_local_timezone; -use crate::common::write_freeze; - -pub struct CreateOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub reason: &'a str, - /// IANA timezone. When `None`, defaults to the system timezone - /// (errors out if undetectable). - pub timezone: Option<&'a str>, - pub start: Option, - pub end: Option, - pub matching_conditions: &'a [String], - pub exclude_conditions: &'a [String], -} - -#[derive(Serialize)] -struct CreatePayload<'a> { - reason: &'a str, - // `Option` rather than `&str` because the API expects - // `null` for missing values (matches Python's - // `start.isoformat() if start is not None else None`), and - // serde flattens `Option::None` to `null` cleanly. Keep these - // owned to avoid juggling lifetimes on the formatted string. - start: Option, - end: Option, - timezone: String, - #[serde(skip_serializing_if = "Option::is_none")] - matching_conditions: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - exclude_conditions: Option>, -} - -/// Run the `freeze create` command. -pub async fn run(opts: CreateOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - let timezone = match opts.timezone { - Some(tz) => tz.to_string(), - None => detect_local_timezone()?, - }; - - let payload = CreatePayload { - reason: opts.reason, - start: opts.start.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), - end: opts.end.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), - timezone, - // Python passes the list when the user supplied any - // matching conditions, and `None` (omitting the key) when - // they didn't. Mirror that — the API may interpret a - // present-but-empty list differently from an absent key. - matching_conditions: if opts.matching_conditions.is_empty() { - None - } else { - Some(opts.matching_conditions.to_vec()) - }, - exclude_conditions: if opts.exclude_conditions.is_empty() { - None - } else { - Some(opts.exclude_conditions.to_vec()) - }, - }; - - output.status(&format!( - "Creating scheduled freeze for {repo}…", - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = format!("/v1/repos/{}/scheduled_freeze", ctx.repository); - let freeze: ScheduledFreeze = client.post(&path, &payload).await?; - - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "Freeze created successfully:")?; - write_freeze(w, &freeze) - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_partial_json; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - use crate::common::parse_naive_datetime; - - #[tokio::test] - async fn run_posts_payload_with_optional_conditions_when_provided() { - let server = MockServer::start().await; - let start = parse_naive_datetime("2099-01-01T00:00:00").unwrap(); - let end = parse_naive_datetime("2099-01-02T00:00:00").unwrap(); - - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/scheduled_freeze")) - .and(header("Authorization", "Bearer t")) - // Use `body_partial_json` so unrelated fields don't make - // the matcher brittle — but include the conditions key - // explicitly to assert it's serialized (the Python - // `if matching_conditions is not None:` branch). - .and(body_partial_json(json!({ - "reason": "emergency-fix", - "start": "2099-01-01T00:00:00", - "end": "2099-01-02T00:00:00", - "timezone": "UTC", - "matching_conditions": ["base=main"], - "exclude_conditions": ["label=hotfix"], - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "11111111-2222-3333-4444-555555555555", - "reason": "emergency-fix", - "start": "2099-01-01T00:00:00", - "end": "2099-01-02T00:00:00", - "timezone": "UTC", - "matching_conditions": ["base=main"], - "exclude_conditions": ["label=hotfix"], - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let matching = ["base=main".to_string()]; - let exclude = ["label=hotfix".to_string()]; - run( - CreateOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - reason: "emergency-fix", - timezone: Some("UTC"), - start: Some(start), - end: Some(end), - matching_conditions: &matching, - exclude_conditions: &exclude, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!( - stdout.contains("Freeze created successfully"), - "got: {stdout}" - ); - assert!( - stdout.contains("11111111-2222-3333-4444-555555555555"), - "got: {stdout}" - ); - assert!(stdout.contains("emergency-fix"), "got: {stdout}"); - } - - #[tokio::test] - async fn run_omits_conditions_keys_when_empty() { - // Python's API client omits `matching_conditions` / - // `exclude_conditions` when the user passes no `-c` / `-e` - // flags. The Mergify API may treat absent-vs-empty - // differently, so the Rust port must keep the same wire - // shape. - let server = MockServer::start().await; - - // Match the request body, capture the raw bytes via a fixed - // response, then read them back through `received_requests`. - Mock::given(method("POST")) - .and(path("/v1/repos/owner/repo/scheduled_freeze")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "abc", - "reason": "no-conditions", - "start": null, - "end": null, - "timezone": "UTC", - "matching_conditions": [], - "exclude_conditions": [], - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let empty: [String; 0] = []; - run( - CreateOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - reason: "no-conditions", - timezone: Some("UTC"), - start: None, - end: None, - matching_conditions: &empty, - exclude_conditions: &empty, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - let map = body.as_object().unwrap(); - assert!( - !map.contains_key("matching_conditions"), - "matching_conditions key must be omitted when empty, got body: {body}" - ); - assert!( - !map.contains_key("exclude_conditions"), - "exclude_conditions key must be omitted when empty, got body: {body}" - ); - // `start` / `end` are always present (with `null` for - // open-ended freezes) — match Python's - // `CreateScheduledFreezePayload` total fields. - assert!(map.contains_key("start")); - assert!(map.contains_key("end")); - assert!(map["start"].is_null()); - assert!(map["end"].is_null()); - } -} diff --git a/crates/mergify-freeze/src/delete.rs b/crates/mergify-freeze/src/delete.rs deleted file mode 100644 index fa2339ec..00000000 --- a/crates/mergify-freeze/src/delete.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! `mergify freeze delete` — remove a scheduled freeze. -//! -//! `POST /v1/repos//scheduled_freeze//delete`. The -//! endpoint is `POST … /delete` (not a `DELETE` verb) because -//! deleting an *active* freeze requires an audit reason — the -//! request body carries `{"delete_reason": ""}`. We mirror -//! Python's payload shape: include `delete_reason` only when the -//! user provided one, otherwise send an empty `{}` (no key). - -use std::io::Write; - -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use serde::Serialize; - -pub struct DeleteOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub freeze_id: &'a str, - /// Required by the API when the target freeze is active; the - /// CLI doesn't validate ahead of time and lets the server - /// reject a missing reason for an active freeze with its own - /// 4xx + message. - pub delete_reason: Option<&'a str>, -} - -#[derive(Serialize, Default)] -struct DeletePayload<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - delete_reason: Option<&'a str>, -} - -/// Run the `freeze delete` command. -pub async fn run(opts: DeleteOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - output.status(&format!( - "Deleting scheduled freeze {id} on {repo}…", - id = opts.freeze_id, - repo = ctx.repository, - ))?; - - let payload = DeletePayload { - delete_reason: opts.delete_reason, - }; - - let client = ctx.mergify_client()?; - let path = format!( - "/v1/repos/{repo}/scheduled_freeze/{id}/delete", - repo = ctx.repository, - id = opts.freeze_id, - ); - client.post_no_response(&path, &payload).await?; - - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "Freeze deleted successfully.") - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[tokio::test] - async fn run_posts_empty_body_when_no_reason_provided() { - let server = MockServer::start().await; - let freeze_id = "abc-123"; - Mock::given(method("POST")) - .and(path(format!( - "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}/delete" - ))) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - DeleteOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - freeze_id, - delete_reason: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - let map = body.as_object().unwrap(); - // Active vs inactive: an empty body is the right shape when - // the freeze isn't active. The server decides whether to - // require the key. - assert!(map.is_empty(), "expected `{{}}` body, got {body}"); - - let stdout = cap.stdout(); - assert!( - stdout.contains("Freeze deleted successfully"), - "got: {stdout}" - ); - } - - #[tokio::test] - async fn run_includes_delete_reason_when_provided() { - let server = MockServer::start().await; - let freeze_id = "abc-123"; - Mock::given(method("POST")) - .and(path(format!( - "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}/delete" - ))) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - DeleteOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - freeze_id, - delete_reason: Some("audit-trail"), - }, - &mut cap.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - assert_eq!( - body.get("delete_reason").and_then(|v| v.as_str()), - Some("audit-trail"), - "got body: {body}" - ); - } -} diff --git a/crates/mergify-freeze/src/lib.rs b/crates/mergify-freeze/src/lib.rs deleted file mode 100644 index ffc0466a..00000000 --- a/crates/mergify-freeze/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Native Rust implementation of the `mergify freeze` subcommands. -//! -//! Each freeze subcommand owns a module. `list` is a read-only GET; -//! `create`/`update`/`delete` mutate the `/v1/repos//scheduled_freeze` -//! resource and share a small block of helpers in [`common`] — -//! "print one freeze", naive-datetime parsing, system-timezone -//! detection. - -pub mod common; -pub mod create; -pub mod delete; -pub mod list; -pub mod update; diff --git a/crates/mergify-freeze/src/list.rs b/crates/mergify-freeze/src/list.rs deleted file mode 100644 index e945ea72..00000000 --- a/crates/mergify-freeze/src/list.rs +++ /dev/null @@ -1,476 +0,0 @@ -//! `mergify freeze list` — list scheduled freezes for a repository. -//! -//! `GET /v1/repos//scheduled_freeze`. Two output modes: -//! -//! - `--json`: pretty-prints the inner `scheduled_freezes` array -//! verbatim. Mirrors Python's `list_cmd`, which returns -//! `response.json()["scheduled_freezes"]` and feeds it to -//! `json.dumps`. The schema is Mergify's API contract, not this -//! CLI's, so unknown fields survive the round trip. -//! - Human (default): a table with columns ID / Reason / Start / -//! End / Conditions / Status. The status column is a best-effort -//! active-vs-scheduled flag: the API returns `start` as a naive -//! timestamp in the freeze's own timezone, but we compare against -//! UTC `now` because we don't have the server's local clock. Same -//! approximation Python's `_is_active` makes. - -use std::io::Write; - -use anstyle::AnsiColor; -use chrono::DateTime; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use mergify_tui::Theme; - -use crate::common::ScheduledFreeze; -use crate::common::format_datetime; -use crate::common::parse_naive_datetime; - -pub struct ListOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub output_json: bool, -} - -/// Run the `freeze list` command. -pub async fn run(opts: ListOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - output.status(&format!( - "Fetching scheduled freezes for {repo}…", - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = format!("/v1/repos/{}/scheduled_freeze", ctx.repository); - let raw: serde_json::Value = client.get(&path).await?; - - // Python's `list_freezes` returns `data["scheduled_freezes"]` - // and the CLI prints that inner array verbatim. Treat a missing - // key as an empty list so a future server quirk doesn't 500 - // the renderer. - let freezes = raw - .get("scheduled_freezes") - .cloned() - .unwrap_or_else(|| serde_json::Value::Array(Vec::new())); - - if opts.output_json { - output.emit_json_value(&freezes)?; - return Ok(()); - } - - let views: Vec = serde_json::from_value(freezes) - .map_err(|e| CliError::Generic(format!("decode scheduled freezes response: {e}")))?; - emit_human(output, &views, Utc::now())?; - Ok(()) -} - -fn emit_human( - output: &mut dyn Output, - freezes: &[ScheduledFreeze], - now: DateTime, -) -> std::io::Result<()> { - let theme = Theme::detect(); - output.emit(&(), &mut |w: &mut dyn Write| { - if freezes.is_empty() { - writeln!(w, "No scheduled freezes found.")?; - return Ok(()); - } - render_table(w, &theme, freezes, now) - }) -} - -const HEADERS: [&str; 6] = ["ID", "Reason", "Start", "End", "Conditions", "Status"]; - -fn render_table( - w: &mut dyn Write, - theme: &Theme, - freezes: &[ScheduledFreeze], - now: DateTime, -) -> std::io::Result<()> { - writeln!( - w, - "{B}Scheduled Freezes{R}", - B = theme.bold, - R = theme.reset - )?; - writeln!(w)?; - - let rows: Vec<[String; 6]> = freezes.iter().map(|f| row_for(f, now)).collect(); - - let widths = column_widths(&rows); - - write_row(w, theme, &HEADERS.map(String::from), &widths, true)?; - write_separator(w, &widths)?; - for row in &rows { - write_row(w, theme, row, &widths, false)?; - } - Ok(()) -} - -fn row_for(freeze: &ScheduledFreeze, now: DateTime) -> [String; 6] { - let id = freeze.id.clone().unwrap_or_default(); - let reason = freeze.reason.clone().unwrap_or_default(); - let timezone = freeze.timezone.as_deref().unwrap_or(""); - let start = format_datetime(freeze.start.as_deref(), timezone); - let end = format_datetime(freeze.end.as_deref(), timezone); - let conditions = format_conditions(&freeze.matching_conditions, &freeze.exclude_conditions); - let status = if is_active(freeze.start.as_deref(), now) { - "active".to_string() - } else { - "scheduled".to_string() - }; - [id, reason, start, end, conditions, status] -} - -/// Build the Conditions cell: matching conditions joined with `, `, -/// followed by `(exclude: …)` when any exclude conditions are set. -/// Same formatting as Python's `_print_freeze_table`. -fn format_conditions(matching: &[String], exclude: &[String]) -> String { - let mut out = matching.join(", "); - if !exclude.is_empty() { - // Python always prefixes " (exclude: …)" with a leading - // space (`conditions += f" (exclude: …)"`), so an - // exclude-only cell keeps the leading space too. - out.push_str(" (exclude: "); - out.push_str(&exclude.join(", ")); - out.push(')'); - } - out -} - -/// Best-effort active flag — see module docs for the timezone caveat -/// (same one the Python implementation acknowledges). Delegates the -/// ISO-8601 parse to [`parse_naive_datetime`] so the CLI-input parser -/// and the API-response parser stay locked. A malformed `start` falls -/// through to "scheduled" instead of aborting the render. -fn is_active(start: Option<&str>, now: DateTime) -> bool { - let Some(start) = start else { - return false; - }; - let Ok(naive_start) = parse_naive_datetime(start) else { - return false; - }; - naive_start <= now.naive_utc() -} - -fn column_widths(rows: &[[String; 6]]) -> [usize; 6] { - let mut widths = HEADERS.map(str::len); - for row in rows { - for (i, cell) in row.iter().enumerate() { - widths[i] = widths[i].max(cell.chars().count()); - } - } - widths -} - -fn write_row( - w: &mut dyn Write, - theme: &Theme, - row: &[String; 6], - widths: &[usize; 6], - header: bool, -) -> std::io::Result<()> { - for (i, cell) in row.iter().enumerate() { - if i > 0 { - write!(w, " ")?; - } - let pad = widths[i].saturating_sub(cell.chars().count()); - if header { - write!( - w, - "{B}{cell}{R}{spaces}", - B = theme.bold, - R = theme.reset, - spaces = " ".repeat(pad), - )?; - } else if HEADERS[i] == "Status" { - // `Theme::fg` already collapses to `Style::new()` when - // colors are disabled — no need for an extra branch. - let style = if cell == "active" { - theme.fg(AnsiColor::Green) - } else { - theme.fg(AnsiColor::Yellow) - }; - write!( - w, - "{S}{cell}{R}{spaces}", - S = style, - R = theme.reset, - spaces = " ".repeat(pad), - )?; - } else { - write!(w, "{cell}{spaces}", spaces = " ".repeat(pad))?; - } - } - writeln!(w) -} - -fn write_separator(w: &mut dyn Write, widths: &[usize; 6]) -> std::io::Result<()> { - for (i, width) in widths.iter().enumerate() { - if i > 0 { - write!(w, " ")?; - } - write!(w, "{}", "─".repeat(*width))?; - } - writeln!(w) -} - -#[cfg(test)] -mod tests { - use mergify_core::OutputMode; - use mergify_test_support::Captured; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - fn freeze_sample() -> serde_json::Value { - json!({ - "id": "11111111-2222-3333-4444-555555555555", - "reason": "emergency-fix", - "start": "2026-01-01T10:00:00", - "end": "2026-01-01T12:00:00", - "timezone": "Europe/Paris", - "matching_conditions": ["base=main"], - "exclude_conditions": ["label=hotfix"], - }) - } - - async fn arrange(server: &MockServer, body: serde_json::Value) { - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/scheduled_freeze")) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(200).set_body_json(body)) - .expect(1) - .mount(server) - .await; - } - - #[tokio::test] - async fn run_json_passthrough_emits_inner_array() { - // Python's `list_freezes` returns `data["scheduled_freezes"]` - // — the inner array, not the wrapping object. The Rust port - // must preserve that contract: `--json` mode prints exactly - // the array, including any unknown fields on the freeze - // objects. - let server = MockServer::start().await; - let mut freeze = freeze_sample(); - freeze["future_field"] = json!("preserved"); - let body = json!({"scheduled_freezes": [freeze.clone()]}); - arrange(&server, body).await; - - let mut cap = Captured::new(OutputMode::Json); - let api_url = server.uri(); - run( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - output_json: true, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed, json!([freeze])); - } - - #[tokio::test] - async fn run_json_passthrough_empty_array() { - // Server returns an empty list — JSON mode must still emit - // `[]` (not `null`, not the wrapping object). - let server = MockServer::start().await; - arrange(&server, json!({"scheduled_freezes": []})).await; - - let mut cap = Captured::new(OutputMode::Json); - let api_url = server.uri(); - run( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - output_json: true, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed, json!([])); - } - - #[tokio::test] - async fn run_human_renders_empty_message() { - let server = MockServer::start().await; - arrange(&server, json!({"scheduled_freezes": []})).await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!( - stdout.contains("No scheduled freezes found"), - "got: {stdout:?}" - ); - } - - #[tokio::test] - async fn run_human_renders_table_with_columns() { - let server = MockServer::start().await; - arrange(&server, json!({"scheduled_freezes": [freeze_sample()]})).await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("Scheduled Freezes"), "got: {stdout}"); - assert!(stdout.contains("emergency-fix"), "got: {stdout}"); - assert!( - stdout.contains("2026-01-01T10:00:00 (Europe/Paris)"), - "got: {stdout}", - ); - assert!( - stdout.contains("2026-01-01T12:00:00 (Europe/Paris)"), - "got: {stdout}", - ); - // Matching + exclude conditions rendered together. - assert!(stdout.contains("base=main"), "got: {stdout}"); - assert!(stdout.contains("(exclude: label=hotfix)"), "got: {stdout}"); - } - - #[tokio::test] - async fn run_human_renders_dash_for_open_ended_freeze() { - // `end == null` is a real API state — emergency freezes have - // no scheduled lift. The table should show `"-"` (Python's - // `_format_datetime(None, …) → "-"`), not the literal word - // "null" or a parse error. - let server = MockServer::start().await; - arrange( - &server, - json!({ - "scheduled_freezes": [{ - "id": "abc", - "reason": "emergency", - "start": "2026-01-01T10:00:00", - "end": null, - "timezone": "UTC", - "matching_conditions": [], - "exclude_conditions": [], - }], - }), - ) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ListOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - // The end column should render as a bare `-` (we don't pin - // the surrounding whitespace because the table's column - // widths depend on the row content). - assert!(stdout.contains(" - "), "got: {stdout}"); - } - - #[test] - fn is_active_past_start_is_active() { - let now = Utc::now(); - // 1h before now → active. - let start = (now - chrono::Duration::hours(1)) - .format("%Y-%m-%dT%H:%M:%S") - .to_string(); - assert!(is_active(Some(&start), now)); - } - - #[test] - fn is_active_future_start_is_scheduled() { - let now = Utc::now(); - let start = (now + chrono::Duration::hours(1)) - .format("%Y-%m-%dT%H:%M:%S") - .to_string(); - assert!(!is_active(Some(&start), now)); - } - - #[test] - fn is_active_missing_or_unparseable_is_scheduled() { - let now = Utc::now(); - // Missing start — degrade to "scheduled" rather than - // panicking. Matches Python's behavior: it would raise on - // `fromisoformat(None)`, but our serde decoder lets the - // field be absent, so the renderer falls through here. - assert!(!is_active(None, now)); - assert!(!is_active(Some("not a date"), now)); - } - - #[test] - fn format_conditions_matching_only() { - let m = vec!["base=main".to_string(), "label=ready".to_string()]; - assert_eq!(format_conditions(&m, &[]), "base=main, label=ready"); - } - - #[test] - fn format_conditions_with_exclude() { - let m = vec!["base=main".to_string()]; - let e = vec!["label=hotfix".to_string()]; - assert_eq!( - format_conditions(&m, &e), - "base=main (exclude: label=hotfix)", - ); - } - - #[test] - fn format_conditions_exclude_only() { - // Python emits a leading space before `(exclude: …)` even - // when there are no matching conditions (`conditions += f" - // (exclude: …)"` on an empty string). Mirror that. - let m: Vec = vec![]; - let e = vec!["label=hotfix".to_string()]; - assert_eq!(format_conditions(&m, &e), " (exclude: label=hotfix)"); - } -} diff --git a/crates/mergify-freeze/src/update.rs b/crates/mergify-freeze/src/update.rs deleted file mode 100644 index 578d1834..00000000 --- a/crates/mergify-freeze/src/update.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! `mergify freeze update` — modify an existing scheduled freeze. -//! -//! `PATCH /v1/repos//scheduled_freeze/`. The payload -//! includes only the fields the user actually changed (Python's -//! `UpdateScheduledFreezePayload` is a `TypedDict` whose entries -//! are conditionally inserted) — we replicate that with -//! `skip_serializing_if = "Option::is_none"` so unspecified fields -//! stay absent on the wire rather than being sent as `null`. - -use std::io::Write; - -use chrono::NaiveDateTime; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use serde::Serialize; - -use crate::common::NaiveDateTimeWire; -use crate::common::ScheduledFreeze; -use crate::common::write_freeze; - -pub struct UpdateOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub freeze_id: &'a str, - pub reason: Option<&'a str>, - pub timezone: Option<&'a str>, - pub start: Option, - pub end: Option, - /// `None` omits the key on the wire (PATCH semantics: don't - /// touch the stored list). `Some(slice)` sends `slice` as the - /// new value — including `Some(&[])`, which clears the list on - /// the server side. The CLI boundary always maps "user didn't - /// pass `-c`" to `None` and "user passed `-c` at least once" - /// to `Some(non-empty)`, so callers going through - /// `mergify freeze update` never observe `Some(&[])`; that - /// shape is reserved for direct programmatic use of this API. - pub matching_conditions: Option<&'a [String]>, - pub exclude_conditions: Option<&'a [String]>, -} - -#[derive(Serialize, Default)] -struct UpdatePayload<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - reason: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - timezone: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - start: Option, - #[serde(skip_serializing_if = "Option::is_none")] - end: Option, - #[serde(skip_serializing_if = "Option::is_none")] - matching_conditions: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - exclude_conditions: Option>, -} - -/// Run the `freeze update` command. -pub async fn run(opts: UpdateOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - let payload = UpdatePayload { - reason: opts.reason, - timezone: opts.timezone, - start: opts.start.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), - end: opts.end.as_ref().map(|dt| NaiveDateTimeWire(dt).iso()), - matching_conditions: opts.matching_conditions.map(<[String]>::to_vec), - exclude_conditions: opts.exclude_conditions.map(<[String]>::to_vec), - }; - - output.status(&format!( - "Updating scheduled freeze {id} on {repo}…", - id = opts.freeze_id, - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = format!( - "/v1/repos/{repo}/scheduled_freeze/{id}", - repo = ctx.repository, - id = opts.freeze_id, - ); - let freeze: ScheduledFreeze = client.patch(&path, &payload).await?; - - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "Freeze updated successfully:")?; - write_freeze(w, &freeze) - })?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[tokio::test] - async fn run_patches_only_user_supplied_fields() { - // The whole point of PATCH semantics: only the fields the - // user changed go on the wire. Mirrors Python's "if - // is not None: payload[] = ..." chain in - // `update_freeze`. - let server = MockServer::start().await; - let freeze_id = "11111111-2222-3333-4444-555555555555"; - Mock::given(method("PATCH")) - .and(path(format!( - "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}" - ))) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": freeze_id, - "reason": "new-reason", - "start": "2099-01-01T00:00:00", - "end": null, - "timezone": "UTC", - "matching_conditions": [], - "exclude_conditions": [], - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - UpdateOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - freeze_id, - reason: Some("new-reason"), - timezone: None, - start: None, - end: None, - matching_conditions: None, - exclude_conditions: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - let map = body.as_object().unwrap(); - assert_eq!( - map.get("reason").and_then(|v| v.as_str()), - Some("new-reason") - ); - // Only `reason` was set — every other field must be absent - // from the request body so the server's PATCH semantics - // leave them untouched. - for absent in [ - "timezone", - "start", - "end", - "matching_conditions", - "exclude_conditions", - ] { - assert!( - !map.contains_key(absent), - "{absent} must be omitted, got body: {body}" - ); - } - - let stdout = cap.stdout(); - assert!( - stdout.contains("Freeze updated successfully"), - "got: {stdout}" - ); - assert!(stdout.contains("new-reason"), "got: {stdout}"); - } - - #[tokio::test] - async fn run_sends_empty_conditions_list_when_provided() { - // `Some(&[])` (explicit "clear the conditions list") is - // wire-different from `None` (don't touch). The Mergify API - // distinguishes them, and Python sends the empty list when - // the user passes the flag. Mirror that. - let server = MockServer::start().await; - let freeze_id = "abc"; - Mock::given(method("PATCH")) - .and(path(format!( - "/v1/repos/owner/repo/scheduled_freeze/{freeze_id}" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": freeze_id, - "reason": "r", - "start": null, - "end": null, - "timezone": "UTC", - "matching_conditions": [], - "exclude_conditions": [], - }))) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let empty: [String; 0] = []; - run( - UpdateOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - freeze_id, - reason: None, - timezone: None, - start: None, - end: None, - matching_conditions: Some(&empty), - exclude_conditions: None, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let requests = server.received_requests().await.unwrap(); - let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); - let map = body.as_object().unwrap(); - assert!(map.contains_key("matching_conditions")); - assert_eq!(map["matching_conditions"], json!([])); - assert!(!map.contains_key("exclude_conditions")); - } -} diff --git a/crates/mergify-queue/Cargo.toml b/crates/mergify-queue/Cargo.toml deleted file mode 100644 index bb9eae3e..00000000 --- a/crates/mergify-queue/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "mergify-queue" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify queue` subcommands." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } -mergify-events = { path = "../mergify-events" } -mergify-tui = { path = "../mergify-tui" } -anstyle = { workspace = true } -chrono = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -mergify-test-support = { path = "../mergify-test-support" } -tokio = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-queue/src/lib.rs b/crates/mergify-queue/src/lib.rs deleted file mode 100644 index 63e9014c..00000000 --- a/crates/mergify-queue/src/lib.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Native Rust implementation of the `mergify queue` subcommands. -//! -//! Hosts `pause` / `unpause` (idempotent API mutations), `status` -//! (read-only batch tree + waiting list, with JSON passthrough), -//! and `show` (per-PR detail with checks + conditions tree, plus the -//! activity-log fallback — `mergify_events::queue_leave` — that tells -//! a dequeued PR apart from a never-queued one). - -pub mod pause; -pub mod show; -pub mod status; -pub mod unpause; diff --git a/crates/mergify-queue/src/pause.rs b/crates/mergify-queue/src/pause.rs deleted file mode 100644 index f496f406..00000000 --- a/crates/mergify-queue/src/pause.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! `mergify queue pause` — pause the merge queue for a repository. -//! -//! PUTs ``{"reason": "..."}`` to -//! ``/v1/repos//merge-queue/pause``. Prints a "⚠ Queue -//! paused" confirmation with the reason (and a coarse relative -//! "since" delta if the API returned a pause timestamp). -//! -//! Confirmation flow: -//! -//! - ``--yes-i-am-sure`` skips the prompt. -//! - Interactive (TTY): asks "Proceed? [y/N]"; anything other than -//! "y"/"yes" aborts with a generic error. -//! - Non-interactive (no TTY, no ``--yes-i-am-sure``): refuses with -//! an ``INVALID_STATE`` error matching Python's behavior. - -use std::io::IsTerminal; -use std::io::Write; - -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use mergify_tui::relative_time; -use serde::Deserialize; -use serde::Serialize; - -const MAX_REASON_LEN: usize = 255; - -pub struct PauseOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub reason: &'a str, - pub yes_i_am_sure: bool, -} - -/// Clap value-parser for the positional `--reason` flag. -/// -/// # Errors -/// -/// Returns a message when `value` exceeds 255 user-visible -/// characters. We count via [`str::chars`] (Unicode scalar values) -/// rather than [`str::len`] (UTF-8 bytes), so non-ASCII reasons -/// such as `"déploiement"` aren't rejected for being below 255 -/// chars but above 255 bytes. -pub fn parse_reason(value: &str) -> Result { - if value.chars().count() > MAX_REASON_LEN { - Err("must be 255 characters or fewer".to_string()) - } else { - Ok(value.to_string()) - } -} - -#[derive(Serialize)] -struct PauseRequest<'a> { - reason: &'a str, -} - -#[derive(Deserialize)] -struct PauseResponse { - // Both fields are optional defensively: the API has historically - // tolerated `reason: null` (the deleted Python `QueuePauseResponse` - // typed it as `str | None`), so the Rust port matches that - // shape rather than aborting deserialization on a missing or - // null value. - #[serde(default)] - reason: Option, - #[serde(default)] - paused_at: Option, -} - -/// Run the `queue pause` command. -pub async fn run(opts: PauseOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - // Resolve auth/repo first so the prompt names the *actual* repo - // (including the `GITHUB_REPOSITORY` fallback) and so a missing - // repo or token fails loudly *before* we ask for confirmation. - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - confirm( - opts.yes_i_am_sure, - std::io::stdin().is_terminal(), - &ctx.repository, - )?; - - output.status(&format!( - "Pausing merge queue for {repo}…", - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = format!("/v1/repos/{}/merge-queue/pause", ctx.repository); - let resp: PauseResponse = client - .put( - &path, - &PauseRequest { - reason: opts.reason, - }, - ) - .await?; - - emit_confirmation(output, &resp)?; - Ok(()) -} - -/// Decide whether to proceed with a destructive queue-pause. -/// -/// `is_tty` is taken as a parameter (rather than read from -/// `std::io::stdin()` here) so unit tests can exercise the -/// non-TTY branch deterministically — `cargo test` happens to run -/// without a terminal stdin most of the time, but that's not -/// something we want to depend on, especially when developers run -/// `cargo test` from inside an interactive shell. -fn confirm(skip: bool, is_tty: bool, repository: &str) -> Result<(), CliError> { - if skip { - return Ok(()); - } - if !is_tty { - return Err(CliError::InvalidState( - "refusing to pause without confirmation. Pass --yes-i-am-sure to proceed.".to_string(), - )); - } - // Prompt goes to stderr (matches click's `confirm`/`prompt` - // behavior) so users can pipe stdout cleanly without the - // prompt text mixed in. - let mut err = std::io::stderr().lock(); - write!( - err, - "You are about to pause the merge queue for {repository}. Proceed? [y/N]: ", - ) - .map_err(CliError::from)?; - err.flush().map_err(CliError::from)?; - drop(err); - - let mut line = String::new(); - std::io::stdin() - .read_line(&mut line) - .map_err(CliError::from)?; - match line.trim().to_ascii_lowercase().as_str() { - "y" | "yes" => Ok(()), - _ => Err(CliError::Generic("aborted by user".to_string())), - } -} - -fn emit_confirmation(output: &mut dyn Output, response: &PauseResponse) -> std::io::Result<()> { - output.emit(&(), &mut |w: &mut dyn Write| { - match &response.reason { - Some(r) => write!(w, "⚠ Queue paused: \"{r}\"")?, - None => write!(w, "⚠ Queue paused")?, - } - if let Some(ts) = &response.paused_at { - let rel = relative_time(ts, Utc::now(), false); - if !rel.is_empty() { - write!(w, " (since {rel})")?; - } - } - writeln!(w) - }) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[test] - fn parse_reason_accepts_short() { - assert_eq!( - parse_reason("deploying hotfix").unwrap(), - "deploying hotfix" - ); - } - - #[test] - fn parse_reason_rejects_over_255() { - let long = "a".repeat(256); - assert!(parse_reason(&long).is_err()); - } - - #[test] - fn parse_reason_counts_chars_not_bytes() { - // 200 user-visible characters but well above 255 *bytes* - // because each `é` is a 2-byte UTF-8 sequence — we keep it. - let multibyte = "é".repeat(200); - assert!(multibyte.len() > MAX_REASON_LEN); - assert!(multibyte.chars().count() <= MAX_REASON_LEN); - assert!(parse_reason(&multibyte).is_ok()); - } - - #[test] - fn confirm_refuses_without_yes_when_non_tty() { - // Pass `is_tty = false` explicitly so the test exercises - // the non-TTY branch without depending on the runtime - // shape of `cargo test`'s stdin (which can be a TTY when - // run from an interactive shell). - let err = confirm(false, false, "owner/repo").unwrap_err(); - assert!( - matches!(err, CliError::InvalidState(_)), - "expected InvalidState, got {err:?}" - ); - assert_eq!(err.exit_code(), mergify_core::ExitCode::InvalidState); - assert!( - err.to_string().contains("--yes-i-am-sure"), - "message should mention the override flag, got: {err}" - ); - } - - #[test] - fn confirm_skips_when_yes_i_am_sure_is_set() { - // `skip = true` must bypass the TTY check — it's the - // contract of `--yes-i-am-sure`, including in CI where - // stdin isn't a terminal. The `is_tty` value passed here - // is irrelevant. - confirm(true, false, "owner/repo").unwrap(); - } - - #[tokio::test] - async fn run_pauses_and_prints_confirmation() { - let server = MockServer::start().await; - Mock::given(method("PUT")) - .and(path("/v1/repos/owner/repo/merge-queue/pause")) - .and(header("Authorization", "Bearer t")) - .and(body_json(serde_json::json!({"reason": "deploy freeze"}))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "reason": "deploy freeze", - "paused_at": "2026-04-23T12:34:56Z", - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - PauseOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - reason: "deploy freeze", - yes_i_am_sure: true, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("⚠ Queue paused"), "got: {stdout:?}"); - assert!(stdout.contains("deploy freeze"), "got: {stdout:?}"); - // `paused_at` renders as a coarse relative delta (e.g. - // "5m ago"), not the raw RFC-3339 timestamp. - assert!(stdout.contains("(since "), "got: {stdout:?}"); - assert!(stdout.contains(" ago)"), "got: {stdout:?}"); - assert!(!stdout.contains("2026-04-23"), "got: {stdout:?}"); - } -} diff --git a/crates/mergify-queue/src/show.rs b/crates/mergify-queue/src/show.rs deleted file mode 100644 index db055150..00000000 --- a/crates/mergify-queue/src/show.rs +++ /dev/null @@ -1,1175 +0,0 @@ -//! `mergify queue show` — detailed state of a single PR in the -//! merge queue. -//! -//! `GET /v1/repos//merge-queue/pull/`. Two output -//! modes: -//! -//! - `--json`: pretty-prints the raw API response as a single JSON -//! document. The schema is Mergify's API contract, not this CLI's, -//! so unknown fields are preserved. -//! - Human (default): metadata block (position / priority / queue -//! rule / queued / ETA), then a CI-state line and a checks -//! section, then a conditions section. `--verbose` switches the -//! checks summary to a full table and the conditions summary to -//! a tree. -//! -//! 404 responses are special-cased: the API returns 404 for "PR is -//! not currently in the merge queue", which is a routine queryable -//! state rather than a server failure. The command reports it on -//! stdout and exits 0 — a not-queued PR is a normal answer, not an -//! error a script should branch on as an API failure. -//! -//! That 404 is also *overloaded*: it is the same answer for a PR -//! dequeued ten minutes ago on failing CI, a PR nobody ever queued, -//! and a PR number that does not exist. So the 404 path asks the -//! activity log for the pull request's last `action.queue.leave` (see -//! [`mergify_events::queue_leave`]) and reports which of those three -//! worlds it is in, with the dequeue reason and the failing checks' -//! URLs. -//! -//! Contract, unchanged in both modes: **exit 0**, and under `--json` a -//! `queued: false` document. The JSON gains `dequeued` (`true` / -//! `false` / `null` when the log could not be read), `queue_leave`, -//! the raw event verbatim so unknown fields survive, and -//! `queue_leave_head_sha`, the head that diagnosis describes — without -//! it a consumer cannot tell a live failure from one a later push has -//! already superseded. In human mode a PR with no queue history still -//! prints the exact line `PR #N is not in the merge queue` — live -//! smoke tests assert against that substring, which is a stable -//! contract. - -use std::io::Write; - -use anstyle::AnsiColor; -use chrono::DateTime; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use mergify_core::http::Client; -use mergify_events::queue_leave; -use mergify_events::queue_leave::LastLeave; -use mergify_tui::StyledGlyph; -use mergify_tui::Theme; -use mergify_tui::relative_time; -use mergify_tui::tree; -use serde::Deserialize; - -pub struct ShowOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub pr_number: u64, - pub verbose: bool, - pub output_json: bool, -} - -#[derive(Deserialize)] -struct PullView { - number: u64, - #[serde(default)] - queued_at: Option, - #[serde(default)] - estimated_time_of_merge: Option, - #[serde(default)] - position: Option, - #[serde(default)] - priority_rule_name: Option, - #[serde(default)] - queue_rule_name: Option, - /// When the queue will give up on this PR's checks. `--json` - /// passed it through from the start, but the human render dropped - /// it — so a `CHECKS_TIMEOUT` could only be diagnosed after the - /// fact, never seen coming. - #[serde(default)] - checks_timeout_at: Option, - #[serde(default)] - mergeability_check: Option, -} - -#[derive(Deserialize)] -struct MergeabilityCheck { - #[serde(default)] - check_type: Option, - #[serde(default)] - started_at: Option, - ci_state: String, - #[serde(default)] - checks: Vec, - #[serde(default)] - conditions_evaluation: Option, -} - -#[derive(Deserialize)] -struct Check { - name: String, - state: String, -} - -#[derive(Deserialize)] -struct ConditionEvaluation { - #[serde(default)] - label: String, - #[serde(default = "default_match_true")] - r#match: bool, - #[serde(default)] - subconditions: Vec, -} - -// The top-level `conditions_evaluation` payload may legitimately -// omit `match` (it's the aggregator node, not a leaf). Treat a -// missing flag as "matched" so we don't render a spurious failure -// for the root. -const fn default_match_true() -> bool { - true -} - -/// Run the `queue show` command. -pub async fn run(opts: ShowOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - let client = ctx.mergify_client()?; - let path = format!( - "/v1/repos/{repo}/merge-queue/pull/{pr_number}", - repo = ctx.repository, - pr_number = opts.pr_number, - ); - - output.status(&format!( - "Fetching merge queue state for PR #{n}…", - n = opts.pr_number, - ))?; - - let raw: Option = client.get_if_exists(&path).await?; - let Some(raw) = raw else { - emit_not_queued(&client, &ctx.repository, output, &opts).await?; - return Ok(()); - }; - - if opts.output_json { - output.emit_json_value(&raw)?; - return Ok(()); - } - - let view: PullView = serde_json::from_value(raw) - .map_err(|e| CliError::Generic(format!("decode merge queue pull response: {e}")))?; - emit_human(output, &view, opts.verbose)?; - Ok(()) -} - -/// Emit the "PR is not currently in the merge queue" state, with the -/// diagnosis of *why* when the activity log has one. This is a normal -/// answer, not a failure, so the command exits 0 — see the module -/// docs. -/// -/// Reading the activity log is best-effort. A token that can read the -/// merge queue but not the repository's whole event log gets a 403 -/// here; turning that into a command failure would break a command -/// that worked before. So a lookup failure degrades to the plain -/// notice plus a stderr warning, and `--json` reports it as -/// `dequeued: null` with a `queue_leave_error` — never as a silent -/// "no dequeue found", which is the very ambiguity this path exists -/// to remove. -async fn emit_not_queued( - client: &Client, - repository: &str, - output: &mut dyn Output, - opts: &ShowOptions<'_>, -) -> Result<(), CliError> { - let pr_number = opts.pr_number; - let now = Utc::now(); - let lookup = queue_leave::fetch_last(client, repository, pr_number, now).await; - let (leave, error) = match lookup { - Ok(leave) => (leave, None), - Err(e) => { - let message = e.to_string(); - output.status(&format!( - "could not read the activity log to check for a past dequeue: {message}", - ))?; - (None, Some(message)) - } - }; - - if opts.output_json { - output.emit_json_value(¬_queued_json( - pr_number, - leave.as_ref(), - error.as_deref(), - ))?; - return Ok(()); - } - - let theme = Theme::detect(); - output.emit(&(), &mut |w: &mut dyn Write| { - if let Some(leave) = &leave { - return queue_leave::render(w, &theme, leave, pr_number, now, opts.verbose); - } - // The exact wording live smoke tests assert on. It is also - // the truthful headline when the log lookup failed — the PR - // is not in the queue either way; the warning already went - // to stderr. - writeln!(w, "PR #{pr_number} is not in the merge queue")?; - if error.is_none() { - writeln!(w)?; - queue_leave::render_no_activity(w, &theme)?; - } - Ok(()) - })?; - Ok(()) -} - -/// `--json` payload for the not-queued state. `queued: false` is kept -/// verbatim for back-compat; `dequeued` is the tri-state answer -/// (`true` left without merging, `false` never left / merged, `null` -/// undetermined) and `queue_leave` republishes the raw API event so -/// unknown fields survive, exactly as the queued path does. -/// -/// `queue_leave_head_sha` is promoted out of that raw event because a -/// consumer cannot act on the diagnosis without it: the failing checks -/// it reports belong to the head the queue was testing, so a caller -/// that does not compare it against the PR's current head will report -/// a red that a later push has already superseded. -fn not_queued_json( - pr_number: u64, - leave: Option<&LastLeave>, - error: Option<&str>, -) -> serde_json::Value { - // `null` only when the lookup failed. "No leave event in the - // retained window" is a real answer — the PR was not dequeued — - // so it is `false`, not "undetermined". - let dequeued = match error { - Some(_) => None, - None => Some(leave.is_some_and(LastLeave::dequeued)), - }; - let mut payload = serde_json::json!({ - "number": pr_number, - "queued": false, - "dequeued": dequeued, - "queue_leave_head_sha": leave.and_then(LastLeave::head_sha), - "queue_leave": leave.map(|l| l.raw.clone()), - }); - if let Some(error) = error - && let Some(map) = payload.as_object_mut() - { - map.insert("queue_leave_error".to_string(), error.into()); - } - payload -} - -fn emit_human(output: &mut dyn Output, view: &PullView, verbose: bool) -> std::io::Result<()> { - let now = Utc::now(); - let theme = Theme::detect(); - output.emit(&(), &mut |w: &mut dyn Write| { - print_metadata(w, &theme, view, now)?; - - match &view.mergeability_check { - None => { - writeln!(w)?; - writeln!( - w, - " {D}Waiting for mergeability check...{R}", - D = theme.dim, - R = theme.reset, - )?; - } - Some(mc) => { - print_checks_section(w, &theme, mc, verbose, now)?; - if let Some(conditions) = &mc.conditions_evaluation { - print_conditions_section(w, &theme, conditions, verbose)?; - } - } - } - Ok(()) - }) -} - -fn print_metadata( - w: &mut dyn Write, - theme: &Theme, - view: &PullView, - now: DateTime, -) -> std::io::Result<()> { - writeln!( - w, - "{B}PR #{n}{R}", - B = theme.bold, - n = view.number, - R = theme.reset, - )?; - writeln!(w)?; - writeln!( - w, - " Position: {}", - display_or_dash(view.position.map(|n| n.to_string()).as_deref()), - )?; - writeln!( - w, - " Priority: {}", - display_or_dash(view.priority_rule_name.as_deref()), - )?; - writeln!( - w, - " Queue rule: {}", - display_or_dash(view.queue_rule_name.as_deref()), - )?; - writeln!( - w, - " Queued at: {}", - relative_or_raw_or_dash(view.queued_at.as_deref(), now, false), - )?; - writeln!( - w, - " ETA: {}", - relative_or_raw_or_dash(view.estimated_time_of_merge.as_deref(), now, true), - )?; - writeln!( - w, - " CI timeout: {}", - relative_or_raw_or_dash(view.checks_timeout_at.as_deref(), now, true), - ) -} - -fn display_or_dash(value: Option<&str>) -> &str { - value.filter(|s| !s.is_empty()).unwrap_or("-") -} - -fn relative_or_raw_or_dash(value: Option<&str>, now: DateTime, future: bool) -> String { - let Some(raw) = value else { - return "-".to_string(); - }; - let rel = relative_time(raw, now, future); - if rel.is_empty() { - // Unparseable timestamp — show the raw string so the user - // sees *something* rather than a silent dash. - raw.to_string() - } else { - rel - } -} - -fn print_checks_section( - w: &mut dyn Write, - theme: &Theme, - mc: &MergeabilityCheck, - verbose: bool, - now: DateTime, -) -> std::io::Result<()> { - writeln!(w)?; - let glyph = check_state_glyph(theme, &mc.ci_state); - write!( - w, - " CI State: {S}{icon} {state}{R}", - S = glyph.style, - icon = glyph.icon, - state = mc.ci_state, - R = theme.reset, - )?; - if let Some(check_type) = mc.check_type.as_deref().filter(|s| !s.is_empty()) { - write!(w, " {D}{check_type}{R}", D = theme.dim, R = theme.reset)?; - } - if let Some(started) = &mc.started_at { - let rel = relative_time(started, now, false); - if !rel.is_empty() { - write!(w, " {D}started {rel}{R}", D = theme.dim, R = theme.reset)?; - } - } - writeln!(w)?; - - if mc.checks.is_empty() { - return Ok(()); - } - - if verbose { - print_checks_table(w, theme, &mc.checks) - } else { - print_checks_summary(w, theme, &mc.checks) - } -} - -fn print_checks_table(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> std::io::Result<()> { - // First column carries the ` Check` header, so its width is the - // wider of the padded check names and the header label itself - // (mirrors rich's auto-sizing of the " Check" column). - const HEADER_CHECK: &str = " Check"; - let name_col_width = checks - .iter() - .map(|c| 2 + c.name.chars().count()) - .max() - .unwrap_or(0) - .max(HEADER_CHECK.chars().count()); - - // Header row: `Check` / `Status`, dim, matching Python's - // `Table(show_header=True)` column titles. - let header_pad = name_col_width.saturating_sub(HEADER_CHECK.chars().count()); - writeln!( - w, - "{D}{check}{spaces} Status{R}", - D = theme.dim, - check = HEADER_CHECK, - spaces = " ".repeat(header_pad), - R = theme.reset, - )?; - - for check in checks { - let glyph = check_state_glyph(theme, &check.state); - let pad = name_col_width.saturating_sub(2 + check.name.chars().count()); - writeln!( - w, - " {D}{name}{spaces}{R} {S}{icon} {state}{R}", - D = theme.dim, - name = check.name, - spaces = " ".repeat(pad), - R = theme.reset, - S = glyph.style, - icon = glyph.icon, - state = check.state, - )?; - } - Ok(()) -} - -fn print_checks_summary(w: &mut dyn Write, theme: &Theme, checks: &[Check]) -> std::io::Result<()> { - let mut passed: u32 = 0; - let mut pending: u32 = 0; - let mut failed: u32 = 0; - for check in checks { - match check.state.as_str() { - "success" | "neutral" | "skipped" => passed += 1, - "pending" => pending += 1, - _ => failed += 1, - } - } - - write!(w, " Checks: ")?; - write!( - w, - "{S}{passed} passed{R}", - S = theme.fg(AnsiColor::Green), - R = theme.reset, - )?; - if pending > 0 { - write!( - w, - ", {S}{pending} pending{R}", - S = theme.fg(AnsiColor::Blue), - R = theme.reset, - )?; - } - if failed > 0 { - write!( - w, - ", {S}{failed} failed{R}", - S = theme.fg(AnsiColor::Red), - R = theme.reset, - )?; - } - writeln!(w)?; - - for check in checks { - if matches!( - check.state.as_str(), - "failure" | "error" | "timed_out" | "action_required" - ) { - let glyph = check_state_glyph(theme, &check.state); - writeln!( - w, - " {S}{icon} {state}{R} {D}{name}{R}", - S = glyph.style, - icon = glyph.icon, - state = check.state, - R = theme.reset, - D = theme.dim, - name = check.name, - )?; - } - } - Ok(()) -} - -/// Map a check state string to its [`StyledGlyph`], using the -/// single-width terminal vocabulary (✓ ✗ ● ○ —); unknown states fall -/// back to a dim `—` so the renderer never crashes on a new API code. -fn check_state_glyph(theme: &Theme, state: &str) -> StyledGlyph { - match state { - "success" => StyledGlyph::new("✓", theme.fg(AnsiColor::Green)), - "pending" => StyledGlyph::new("●", theme.fg(AnsiColor::Yellow)), - "failure" | "error" | "action_required" | "timed_out" => { - StyledGlyph::new("✗", theme.fg(AnsiColor::Red)) - } - "cancelled" | "neutral" | "skipped" | "stale" => StyledGlyph::new("○", theme.dim), - _ => StyledGlyph::new("—", theme.dim), - } -} - -fn print_conditions_section( - w: &mut dyn Write, - theme: &Theme, - evaluation: &ConditionEvaluation, - verbose: bool, -) -> std::io::Result<()> { - writeln!(w)?; - if verbose { - writeln!(w, "{B}Conditions{R}", B = theme.bold, R = theme.reset)?; - write_condition_tree(w, theme, &evaluation.subconditions, "")?; - return Ok(()); - } - - let top = &evaluation.subconditions; - if top.is_empty() { - return Ok(()); - } - - let met = top.iter().filter(|s| s.r#match).count(); - let total = top.len(); - let style = if met == total { - theme.fg(AnsiColor::Green) - } else { - theme.fg(AnsiColor::Yellow) - }; - writeln!( - w, - " Conditions: {S}{met}/{total} met{R}", - S = style, - R = theme.reset, - )?; - - for sub in top { - if sub.r#match { - continue; - } - let summary = if sub.subconditions.is_empty() { - sub.label.clone() - } else { - summarize_failing_group(sub) - }; - writeln!( - w, - " {S}✗{R} {summary}", - S = theme.fg(AnsiColor::Red), - R = theme.reset, - )?; - } - Ok(()) -} - -fn summarize_failing_group(evaluation: &ConditionEvaluation) -> String { - let labels: Vec = evaluation.subconditions.iter().map(child_label).collect(); - if labels.len() <= 3 { - labels.join(" or ") - } else { - let head: Vec<&str> = labels.iter().take(2).map(String::as_str).collect(); - format!("{} or ({} more)", head.join(" or "), labels.len() - 2) - } -} - -fn child_label(evaluation: &ConditionEvaluation) -> String { - let label = &evaluation.label; - if !is_aggregator(label) { - return label.clone(); - } - let Some(first) = evaluation.subconditions.first() else { - return label.clone(); - }; - if is_aggregator(&first.label) { - child_label(first) - } else { - first.label.clone() - } -} - -fn is_aggregator(label: &str) -> bool { - matches!(label, "all of" | "any of" | "not") -} - -fn write_condition_tree( - w: &mut dyn Write, - theme: &Theme, - nodes: &[ConditionEvaluation], - prefix: &str, -) -> std::io::Result<()> { - if nodes.is_empty() { - return Ok(()); - } - let last = nodes.len() - 1; - for (i, node) in nodes.iter().enumerate() { - let (branch, continuation) = tree::branch_chars(i == last); - let glyph = if node.r#match { - StyledGlyph::new("✓", theme.fg(AnsiColor::Green)) - } else { - StyledGlyph::new("✗", theme.fg(AnsiColor::Red)) - }; - writeln!( - w, - "{prefix}{branch}{S}{icon}{R} {label}", - S = glyph.style, - icon = glyph.icon, - R = theme.reset, - label = node.label, - )?; - let child_prefix = format!("{prefix}{continuation}"); - write_condition_tree(w, theme, &node.subconditions, &child_prefix)?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use mergify_core::OutputMode; - use mergify_test_support::Captured; - use serde_json::json; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - fn pull_response() -> serde_json::Value { - json!({ - "number": 123, - "queued_at": "2026-05-09T10:00:00Z", - "estimated_time_of_merge": "2026-05-09T11:00:00Z", - "position": 3, - "priority_rule_name": "default", - "queue_rule_name": "default", - "checks_timeout_at": "2026-05-09T12:00:00Z", - "queue_rule": {"name": "default", "config": {}}, - "mergeability_check": { - "check_type": "in_place", - "queue_pull_request_number": 123, - "started_at": "2026-05-09T10:05:00Z", - "ci_state": "pending", - "state": "running", - "checks": [ - {"name": "tests", "description": "", "state": "success"}, - {"name": "linters", "description": "", "state": "pending"}, - {"name": "security", "description": "", "state": "failure"}, - ], - "conditions_evaluation": { - "match": false, - "label": "all of", - "subconditions": [ - { - "match": true, - "label": "#check-success=tests", - "subconditions": [], - }, - { - "match": false, - "label": "#check-success=linters", - "subconditions": [], - }, - ], - }, - }, - }) - } - - async fn arrange(server: &MockServer, body: serde_json::Value, status: u16) { - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/pull/123")) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(status).set_body_json(body)) - .expect(1) - .mount(server) - .await; - } - - #[tokio::test] - async fn run_renders_metadata_and_compact_sections() { - let server = MockServer::start().await; - arrange(&server, pull_response(), 200).await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ShowOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number: 123, - verbose: false, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("PR #123"), "got: {stdout:?}"); - assert!(stdout.contains("Position:"), "got: {stdout:?}"); - // A pending checks timeout is visible before it fires, not - // only diagnosable as CHECKS_TIMEOUT afterwards. - assert!(stdout.contains("CI timeout:"), "got: {stdout:?}"); - assert!(stdout.contains("CI State:"), "got: {stdout:?}"); - // Compact summary: 1 passed (tests), 1 pending (linters), 1 - // failed (security). The failing check name is listed below - // the summary line. - assert!(stdout.contains("1 passed"), "got: {stdout:?}"); - assert!(stdout.contains("1 pending"), "got: {stdout:?}"); - assert!(stdout.contains("1 failed"), "got: {stdout:?}"); - assert!(stdout.contains("security"), "got: {stdout:?}"); - // Compact conditions: "1/2 met" + the failing label. - assert!(stdout.contains("1/2 met"), "got: {stdout:?}"); - assert!(stdout.contains("#check-success=linters"), "got: {stdout:?}"); - } - - #[tokio::test] - async fn run_renders_verbose_table_and_tree() { - let server = MockServer::start().await; - arrange(&server, pull_response(), 200).await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ShowOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number: 123, - verbose: true, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - // Verbose table: header row labels both columns. - assert!(stdout.contains("Check"), "got: {stdout:?}"); - assert!(stdout.contains("Status"), "got: {stdout:?}"); - // Verbose table: every check name appears as its own row. - assert!(stdout.contains("tests"), "got: {stdout:?}"); - assert!(stdout.contains("linters"), "got: {stdout:?}"); - assert!(stdout.contains("security"), "got: {stdout:?}"); - // Verbose conditions: tree header + box-drawing characters. - assert!(stdout.contains("Conditions"), "got: {stdout:?}"); - assert!( - stdout.contains("├──") || stdout.contains("└──"), - "got: {stdout:?}" - ); - } - - #[tokio::test] - async fn run_emits_json_passthrough() { - let server = MockServer::start().await; - // Add a synthetic field to verify unknown fields survive - // the round-trip. - let mut body = pull_response(); - body["future_field"] = json!("preserved"); - arrange(&server, body, 200).await; - - let mut cap = Captured::new(OutputMode::Json); - let api_url = server.uri(); - run( - ShowOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number: 123, - verbose: false, - output_json: true, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(parsed["number"], json!(123)); - assert_eq!(parsed["future_field"], json!("preserved")); - } - - /// Mock the merge-queue 404 that sends `queue show` to the - /// activity-log fallback. - async fn arrange_not_queued(server: &MockServer, pr_number: u64) { - Mock::given(method("GET")) - .and(path(format!( - "/v1/repos/owner/repo/merge-queue/pull/{pr_number}" - ))) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(server) - .await; - } - - /// Mock the activity-log lookup with `events`. - async fn arrange_logs(server: &MockServer, events: Vec) { - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "size": events.len(), - "per_page": 1, - "events": events, - }))) - .expect(1) - .mount(server) - .await; - } - - fn dequeue_event() -> serde_json::Value { - json!({ - "id": 1, - "received_at": "2026-07-20T23:25:11.263987Z", - "trigger": "merge queue internal", - "repository": "owner/repo", - "pull_request": 999, - "base_ref": "main", - "outcome": "failure", - "type": "action.queue.leave", - "metadata": { - "reason": "The merge conditions cannot be satisfied due to failing checks", - "merged": false, - "queue_name": "default", - "queued_at": "2026-07-20T23:11:42.944734Z", - "pull_request_head_sha": "31b4a485b8ce6f2c1d0e9a7b4c5d6e7f80910111", - "dequeue_code": "CHECKS_FAILED", - "unsuccessful_checks": [{ - "name": "ci-gate", - "state": "failure", - "details_url": "https://github.com/owner/repo/actions/runs/1/job/2", - }], - }, - }) - } - - async fn run_not_queued(server: &MockServer, pr_number: u64, output_json: bool) -> Captured { - let mut cap = if output_json { - Captured::new(OutputMode::Json) - } else { - Captured::human() - }; - let api_url = server.uri(); - run( - ShowOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number, - verbose: false, - output_json, - }, - &mut cap.output, - ) - .await - .unwrap(); - cap - } - - #[tokio::test] - async fn run_404_human_is_not_in_queue_and_succeeds() { - // A PR with no queue history at all: a normal queryable - // state, not an API failure. Human mode prints the notice on - // stdout and the command returns Ok (exit 0). The wording is - // pinned by live smoke tests. - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![]).await; - - let cap = run_not_queued(&server, 999, false).await; - let stdout = cap.stdout(); - assert!( - stdout.contains("PR #999 is not in the merge queue"), - "got: {stdout:?}", - ); - // "we looked and found nothing" is an answer; silence is not. - assert!( - stdout.contains("No merge-queue activity"), - "got: {stdout:?}", - ); - } - - #[tokio::test] - async fn run_404_json_emits_not_queued_document() { - // Under `--json`, the not-queued state is a parseable - // `{number, queued: false}` document on stdout (exit 0), so - // pipeline consumers never get empty output for the common - // case. `queued: false` is back-compat and must not move. - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![]).await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["number"], json!(999)); - assert_eq!(parsed["queued"], json!(false)); - assert_eq!(parsed["dequeued"], json!(false)); - assert_eq!(parsed["queue_leave"], json!(null)); - } - - #[tokio::test] - async fn run_404_human_reports_the_dequeue_reason_and_check_urls() { - // The gap this fallback closes: one line used to cover - // "dequeued 10 minutes ago on failing CI" and "never queued" - // alike. - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![dequeue_event()]).await; - - let cap = run_not_queued(&server, 999, false).await; - let stdout = cap.stdout(); - assert!(stdout.contains("PR #999 was dequeued"), "got: {stdout:?}"); - assert!(stdout.contains("CHECKS_FAILED"), "got: {stdout:?}"); - assert!( - stdout.contains("The merge conditions cannot be satisfied"), - "got: {stdout:?}", - ); - assert!( - stdout.contains("https://github.com/owner/repo/actions/runs/1/job/2"), - "got: {stdout:?}", - ); - assert!(stdout.contains("@mergifyio queue"), "got: {stdout:?}"); - } - - #[tokio::test] - async fn run_404_json_reports_dequeued_with_the_raw_event() { - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![dequeue_event()]).await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["queued"], json!(false)); - assert_eq!(parsed["dequeued"], json!(true)); - // The event is republished verbatim — the schema is - // Mergify's contract, not this CLI's. - assert_eq!(parsed["queue_leave"], dequeue_event()); - } - - #[tokio::test] - async fn run_404_json_promotes_the_head_sha_the_diagnosis_describes() { - // Without this, a consumer reading `unsuccessful_checks` has - // no way to notice the checks belong to a commit a later push - // replaced, and reports a red the PR no longer has. Full SHA, - // not the abbreviation the human render uses — the caller - // compares it against the PR head. - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![dequeue_event()]).await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!( - parsed["queue_leave_head_sha"], - json!("31b4a485b8ce6f2c1d0e9a7b4c5d6e7f80910111"), - ); - } - - #[tokio::test] - async fn run_404_json_head_sha_is_null_when_there_is_no_leave_event() { - // The key is always present, like `dequeued` and - // `queue_leave`, so a consumer can read it unconditionally. - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![]).await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["queue_leave_head_sha"], json!(null)); - } - - #[tokio::test] - async fn run_404_reports_a_merge_as_merged_not_dequeued() { - // A merge is also an `action.queue.leave`. Reporting it as a - // dequeue would tell an agent to requeue a merged PR. - let server = MockServer::start().await; - let mut event = dequeue_event(); - event["metadata"]["merged"] = json!(true); - event["metadata"]["dequeue_code"] = json!("PR_MERGED"); - event["metadata"]["unsuccessful_checks"] = json!([]); - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![event]).await; - - let cap = run_not_queued(&server, 999, false).await; - let stdout = cap.stdout(); - assert!( - stdout.contains("PR #999 was merged by the merge queue"), - "got: {stdout:?}", - ); - assert!(!stdout.contains("dequeued"), "got: {stdout:?}"); - } - - #[tokio::test] - async fn run_404_json_reports_a_merge_as_not_dequeued() { - let server = MockServer::start().await; - let mut event = dequeue_event(); - event["metadata"]["merged"] = json!(true); - arrange_not_queued(&server, 999).await; - arrange_logs(&server, vec![event]).await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["dequeued"], json!(false)); - assert_eq!(parsed["queue_leave"]["metadata"]["merged"], json!(true)); - } - - #[tokio::test] - async fn run_404_degrades_when_the_activity_log_is_unreadable() { - // A queue-scoped token may be refused the repository's event - // log. `queue show` worked before this fallback existed and - // must keep working: exit 0, the notice on stdout, the reason - // on stderr — and never a silent "no dequeue found". - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({"detail": "nope"}))) - .expect(1) - .mount(&server) - .await; - - let cap = run_not_queued(&server, 999, false).await; - let stdout = cap.stdout(); - assert!( - stdout.contains("PR #999 is not in the merge queue"), - "got: {stdout:?}", - ); - assert!( - !stdout.contains("No merge-queue activity"), - "must not claim we looked when the lookup failed: {stdout:?}", - ); - assert!( - cap.stderr().contains("could not read the activity log"), - "got: {:?}", - cap.stderr(), - ); - } - - #[tokio::test] - async fn run_404_json_reports_an_unreadable_log_as_undetermined() { - let server = MockServer::start().await; - arrange_not_queued(&server, 999).await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/logs")) - .respond_with(ResponseTemplate::new(403).set_body_json(json!({"detail": "nope"}))) - .expect(1) - .mount(&server) - .await; - - let cap = run_not_queued(&server, 999, true).await; - let parsed: serde_json::Value = serde_json::from_str(&cap.stdout()).unwrap(); - assert_eq!(parsed["queued"], json!(false)); - // `null`, not `false`: "we could not tell" is not "it was - // never dequeued". - assert_eq!(parsed["dequeued"], json!(null)); - assert!( - parsed["queue_leave_error"] - .as_str() - .is_some_and(|e| e.contains("403")), - "got: {parsed}", - ); - } - - #[tokio::test] - async fn run_no_mergeability_check() { - let server = MockServer::start().await; - let body = json!({ - "number": 123, - "queued_at": "2026-05-09T10:00:00Z", - "position": 1, - "priority_rule_name": "default", - "queue_rule_name": "default", - "queue_rule": {"name": "default", "config": {}}, - "mergeability_check": null, - }); - arrange(&server, body, 200).await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - ShowOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - pr_number: 123, - verbose: false, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!( - stdout.contains("Waiting for mergeability check"), - "got: {stdout:?}", - ); - } - - #[test] - fn summarize_failing_group_two_labels() { - let group = ConditionEvaluation { - label: "any of".to_string(), - r#match: false, - subconditions: vec![ - ConditionEvaluation { - label: "a".to_string(), - r#match: false, - subconditions: vec![], - }, - ConditionEvaluation { - label: "b".to_string(), - r#match: false, - subconditions: vec![], - }, - ], - }; - assert_eq!(summarize_failing_group(&group), "a or b"); - } - - #[test] - fn summarize_failing_group_truncates_at_three_plus() { - let group = ConditionEvaluation { - label: "any of".to_string(), - r#match: false, - subconditions: vec![ - ConditionEvaluation { - label: "a".to_string(), - r#match: false, - subconditions: vec![], - }, - ConditionEvaluation { - label: "b".to_string(), - r#match: false, - subconditions: vec![], - }, - ConditionEvaluation { - label: "c".to_string(), - r#match: false, - subconditions: vec![], - }, - ConditionEvaluation { - label: "d".to_string(), - r#match: false, - subconditions: vec![], - }, - ], - }; - // 4 items: keep first 2, summarize the rest. - assert_eq!(summarize_failing_group(&group), "a or b or (2 more)"); - } - - #[test] - fn child_label_recurses_through_aggregators() { - let nested = ConditionEvaluation { - label: "any of".to_string(), - r#match: false, - subconditions: vec![ConditionEvaluation { - label: "all of".to_string(), - r#match: false, - subconditions: vec![ConditionEvaluation { - label: "leaf".to_string(), - r#match: false, - subconditions: vec![], - }], - }], - }; - assert_eq!(child_label(&nested), "leaf"); - } -} diff --git a/crates/mergify-queue/src/status.rs b/crates/mergify-queue/src/status.rs deleted file mode 100644 index 276bba94..00000000 --- a/crates/mergify-queue/src/status.rs +++ /dev/null @@ -1,914 +0,0 @@ -//! `mergify queue status` — show merge queue status for a repository. -//! -//! `GET /v1/repos//merge-queue/status[?branch=]`. Two -//! output modes: -//! -//! - `--json`: pretty-prints the raw API response as a single JSON -//! document. The schema is Mergify's API contract, not this CLI's, -//! so unknown fields are preserved (deserialize to -//! `serde_json::Value`, emit verbatim). -//! - Human (default): a header, an optional pause indicator, the -//! batch tree (grouped by scope when there is more than one), and -//! the waiting-PR list. Status icons and relative times match the -//! Python implementation. -//! -//! The command does not assume the response shape beyond the fields -//! it actively renders: every nested struct uses -//! `#[serde(default)] Option<…>` for fields the API has historically -//! treated as optional/nullable, so a missing field doesn't abort -//! deserialization. -//! -//! Exit codes: -//! -//! - `0` on a successful render (queue empty, paused, or active). -//! - Standard `CliError` exit codes on auth, API, or -//! parse/serialization errors. - -use std::collections::HashMap; -use std::collections::HashSet; -use std::io::Write; - -use anstyle::AnsiColor; -use chrono::DateTime; -use chrono::Utc; -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::Output; -use mergify_tui::StyledGlyph; -use mergify_tui::Theme; -use mergify_tui::relative_time; -use mergify_tui::tree; -use serde::Deserialize; -use url::form_urlencoded; - -pub struct StatusOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, - pub branch: Option<&'a str>, - pub output_json: bool, -} - -// All view structs use `#[serde(default)] Option<…>` for fields the -// API has historically treated as optional/nullable. The wire format -// is Mergify's API contract — we deserialize only the fields we -// render and accept everything else implicitly via the -// `serde_json::Value` passthrough used in JSON mode. -#[derive(Deserialize)] -struct StatusView { - #[serde(default)] - pause: Option, - #[serde(default)] - batches: Vec, - #[serde(default)] - waiting_pull_requests: Vec, -} - -#[derive(Deserialize)] -struct Pause { - #[serde(default)] - reason: Option, - #[serde(default)] - paused_at: Option, -} - -#[derive(Deserialize)] -struct Batch { - id: String, - #[serde(default)] - parent_ids: Vec, - #[serde(default)] - scopes: Vec, - status: BatchStatus, - #[serde(default)] - started_at: Option, - #[serde(default)] - estimated_merge_at: Option, - checks_summary: ChecksSummary, - #[serde(default)] - pull_requests: Vec, -} - -#[derive(Deserialize)] -struct BatchStatus { - code: String, -} - -#[derive(Deserialize)] -struct ChecksSummary { - #[serde(default)] - passed: u64, - #[serde(default)] - total: u64, -} - -#[derive(Deserialize)] -struct PullRequest { - number: u64, - title: String, - author: Author, - #[serde(default)] - queued_at: Option, - #[serde(default)] - priority_alias: Option, - #[serde(default)] - estimated_merge_at: Option, -} - -#[derive(Deserialize)] -struct Author { - login: String, -} - -/// Run the `queue status` command. -pub async fn run(opts: StatusOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - output.status(&format!( - "Fetching merge queue status for {repo}…", - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = build_path(&ctx.repository, opts.branch); - - let raw: serde_json::Value = client.get(&path).await?; - - if opts.output_json { - output.emit_json_value(&raw)?; - } else { - let view: StatusView = serde_json::from_value(raw) - .map_err(|e| CliError::Generic(format!("decode merge queue status response: {e}")))?; - emit_human(output, &ctx.repository, &view)?; - } - Ok(()) -} - -fn build_path(repository: &str, branch: Option<&str>) -> String { - let mut path = format!("/v1/repos/{repository}/merge-queue/status"); - if let Some(branch) = branch { - // form_urlencoded::byte_serialize handles spaces, unicode and - // reserved characters. Unencoded slashes are tolerated by - // most servers but encoding is the safe contract. - let encoded: String = form_urlencoded::byte_serialize(branch.as_bytes()).collect(); - path.push_str("?branch="); - path.push_str(&encoded); - } - path -} - -fn emit_human(output: &mut dyn Output, repository: &str, view: &StatusView) -> std::io::Result<()> { - let now = Utc::now(); - let theme = Theme::detect(); - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!( - w, - "{B}Merge Queue: {repository}{R}", - B = theme.bold, - R = theme.reset - )?; - writeln!(w)?; - - if let Some(pause) = &view.pause { - print_pause(w, &theme, pause, now)?; - writeln!(w)?; - } - - if view.batches.is_empty() && view.waiting_pull_requests.is_empty() { - writeln!(w, "{D}Queue is empty{R}", D = theme.dim, R = theme.reset)?; - return Ok(()); - } - - if !view.batches.is_empty() { - print_batches(w, &theme, &view.batches, now)?; - } - - if !view.waiting_pull_requests.is_empty() { - if !view.batches.is_empty() { - writeln!(w)?; - } - print_waiting_prs(w, &theme, &view.waiting_pull_requests, now)?; - } - Ok(()) - }) -} - -/// Map a queue batch status code to its [`StyledGlyph`]. Mirrors -/// Python's `STATUS_STYLES` (icon + color side); unknown codes fall -/// back to a dim `?`. -fn batch_glyph(theme: &Theme, code: &str) -> StyledGlyph { - // `theme.fg` already collapses to `Style::new()` when colors are - // off — except for `merged`, which we compose as `green.dimmed()`. - // When colors are off the `dimmed` attribute would still emit a - // dim escape, so short-circuit here to keep the plain output - // truly plain. - let style = if theme.enabled { - match code { - "running" => theme.fg(AnsiColor::Green), - // Python rendered `merged` as `"dim green"` — bold off, - // green on, dimmed. anstyle composes the same effect by - // setting `dimmed()` on the green style. - "merged" => theme.fg(AnsiColor::Green).dimmed(), - "failed" => theme.fg(AnsiColor::Red), - "bisecting" - | "preparing" - | "waiting_for_previous_batches" - | "waiting_for_requeue" - | "waiting_schedule" => theme.fg(AnsiColor::Yellow), - "waiting_for_merge" | "frozen" => theme.fg(AnsiColor::Cyan), - _ => theme.dim, - } - } else { - anstyle::Style::new() - }; - let icon = match code { - "running" => "●", - "bisecting" => "◑", - "preparing" => "◌", - "failed" => "✗", - "merged" => "✓", - "waiting_for_merge" => "◎", - "waiting_for_previous_batches" | "waiting_for_batch" => "⏳", - "waiting_for_requeue" => "↻", - "waiting_schedule" => "⏰", - "frozen" => "❄", - _ => "?", - }; - StyledGlyph::new(icon, style) -} - -fn print_pause( - w: &mut dyn Write, - theme: &Theme, - pause: &Pause, - now: DateTime, -) -> std::io::Result<()> { - let reason = pause.reason.as_deref().unwrap_or(""); - // Only the warning prefix gets the `warn` (bold yellow) style; - // the quoted reason renders plain so it reads as content, not - // as part of the warning text. Mirrors the Python rendering. - write!( - w, - "{W}⚠ Queue is paused:{R} \"{reason}\"", - W = theme.warn, - R = theme.reset, - )?; - if let Some(ts) = &pause.paused_at { - let rel = relative_time(ts, now, false); - if !rel.is_empty() { - write!(w, " {D}(since {rel}){R}", D = theme.dim, R = theme.reset)?; - } - } - writeln!(w) -} - -fn print_batches( - w: &mut dyn Write, - theme: &Theme, - batches: &[Batch], - now: DateTime, -) -> std::io::Result<()> { - let sorted = topological_sort(batches); - let groups = group_by_scope(&sorted); - let single_scope = groups.len() == 1; - - for (i, (scope, scope_batches)) in groups.iter().enumerate() { - if i > 0 { - writeln!(w)?; - } - let label = if single_scope { - "Batches" - } else { - scope.as_str() - }; - writeln!(w, "{B}{label}{R}", B = theme.bold, R = theme.reset)?; - - let last_batch_idx = scope_batches.len() - 1; - for (bi, batch) in scope_batches.iter().enumerate() { - let (branch, continuation) = tree::branch_chars(bi == last_batch_idx); - print_batch_line(w, theme, branch, batch, now)?; - print_batch_prs(w, theme, continuation, batch)?; - } - } - Ok(()) -} - -fn print_batch_line( - w: &mut dyn Write, - theme: &Theme, - branch: &str, - batch: &Batch, - now: DateTime, -) -> std::io::Result<()> { - let glyph = batch_glyph(theme, &batch.status.code); - write!( - w, - "{branch}{S}{icon} {code}{R}", - S = glyph.style, - icon = glyph.icon, - code = batch.status.code, - R = theme.reset, - )?; - if batch.checks_summary.total > 0 { - write!( - w, - " {D}checks {p}/{t}{R}", - D = theme.dim, - p = batch.checks_summary.passed, - t = batch.checks_summary.total, - R = theme.reset, - )?; - } - if let Some(started) = &batch.started_at { - let rel = relative_time(started, now, false); - if !rel.is_empty() { - write!(w, " {D}{rel}{R}", D = theme.dim, R = theme.reset)?; - } - } - if let Some(eta) = &batch.estimated_merge_at { - let rel = relative_time(eta, now, true); - if !rel.is_empty() { - write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; - } - } - writeln!(w) -} - -fn print_batch_prs( - w: &mut dyn Write, - theme: &Theme, - continuation: &str, - batch: &Batch, -) -> std::io::Result<()> { - if batch.pull_requests.is_empty() { - return Ok(()); - } - let last_pr_idx = batch.pull_requests.len() - 1; - for (pi, pr) in batch.pull_requests.iter().enumerate() { - let (pr_branch, _) = tree::branch_chars(pi == last_pr_idx); - writeln!( - w, - "{continuation}{pr_branch}{N}#{num}{R} {title} {A}({author}){R}", - N = theme.cyan, - num = pr.number, - title = pr.title, - A = theme.dim, - author = pr.author.login, - R = theme.reset, - )?; - } - Ok(()) -} - -fn print_waiting_prs( - w: &mut dyn Write, - theme: &Theme, - prs: &[PullRequest], - now: DateTime, -) -> std::io::Result<()> { - writeln!(w, "{B}Waiting{R}", B = theme.bold, R = theme.reset)?; - for pr in prs { - write!( - w, - " {N}#{num}{R} {title} {A}{author}{R}", - N = theme.cyan, - num = pr.number, - title = pr.title, - A = theme.dim, - author = pr.author.login, - R = theme.reset, - )?; - if let Some(prio) = &pr.priority_alias { - write!(w, " {P}{prio}{R}", P = theme.magenta, R = theme.reset)?; - } - if let Some(queued_at) = &pr.queued_at { - let rel = relative_time(queued_at, now, false); - if !rel.is_empty() { - write!(w, " {D}queued {rel}{R}", D = theme.dim, R = theme.reset)?; - } - } - if let Some(eta) = &pr.estimated_merge_at { - let rel = relative_time(eta, now, true); - if !rel.is_empty() { - write!(w, " {D}ETA {rel}{R}", D = theme.dim, R = theme.reset)?; - } - } - writeln!(w)?; - } - Ok(()) -} - -/// Topological sort of batches by `parent_ids`. Roots come first, -/// children follow their parents — matches the Python -/// `_topological_sort`. Cycles are impossible by API contract, but -/// the `visited` set makes us tolerant of them anyway. -fn topological_sort(batches: &[Batch]) -> Vec<&Batch> { - let id_to_batch: HashMap<&str, &Batch> = batches.iter().map(|b| (b.id.as_str(), b)).collect(); - let mut visited: HashSet<&str> = HashSet::new(); - let mut result: Vec<&Batch> = Vec::with_capacity(batches.len()); - - for batch in batches { - visit(batch.id.as_str(), &id_to_batch, &mut visited, &mut result); - } - result -} - -fn visit<'a>( - id: &'a str, - id_to_batch: &HashMap<&'a str, &'a Batch>, - visited: &mut HashSet<&'a str>, - result: &mut Vec<&'a Batch>, -) { - if !visited.insert(id) { - return; - } - let Some(batch) = id_to_batch.get(id) else { - return; - }; - for parent in &batch.parent_ids { - visit(parent.as_str(), id_to_batch, visited, result); - } - result.push(batch); -} - -/// Group batches by scope, preserving first-seen order for the -/// scopes (matches Python dict iteration). A batch with no scopes -/// is grouped under `"default"` to match the Python fallback. A -/// batch with multiple scopes appears in every group it claims — -/// the Python implementation does the same so users see each batch -/// in every scope it affects. -/// -/// Returns a `Vec<(scope, batches)>` rather than a hashmap because -/// (a) we need insertion-order iteration and (b) typical N is -/// 1–3 scopes per repo — the linear-lookup `entry_or_insert` -/// below is cheaper than the hashing overhead of `IndexMap` at -/// this scale. -fn group_by_scope<'a>(batches: &[&'a Batch]) -> Vec<(String, Vec<&'a Batch>)> { - let mut groups: Vec<(String, Vec<&Batch>)> = Vec::new(); - for batch in batches { - let scopes: Vec = if batch.scopes.is_empty() { - vec!["default".to_string()] - } else { - batch.scopes.clone() - }; - for scope in scopes { - match groups.iter_mut().find(|(s, _)| *s == scope) { - Some((_, bs)) => bs.push(batch), - None => groups.push((scope, vec![batch])), - } - } - } - groups -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - use wiremock::matchers::query_param; - - use super::*; - - #[test] - fn build_path_no_branch() { - assert_eq!( - build_path("owner/repo", None), - "/v1/repos/owner/repo/merge-queue/status", - ); - } - - #[test] - fn build_path_with_branch() { - assert_eq!( - build_path("owner/repo", Some("main")), - "/v1/repos/owner/repo/merge-queue/status?branch=main", - ); - } - - #[test] - fn build_path_url_encodes_branch() { - // Slashes and unicode in branch names must survive a round - // trip through the URL — `feature/foo` is common, and - // browser-pasted names occasionally include UTF-8. - let path = build_path("owner/repo", Some("feature/foo bar")); - assert!(path.ends_with("?branch=feature%2Ffoo+bar"), "got {path}"); - } - - // `relative_time` lives in `mergify-tui::time` and is exercised - // there; we re-export it via `mergify_tui::relative_time` and - // don't re-test it here. - - #[test] - fn topological_sort_orders_parents_before_children() { - // Construct three batches, child references parent. Even if - // the input is in reverse order, the sort must put the - // parent first. - let batches = vec![ - sample_batch("c", &["b"]), - sample_batch("b", &["a"]), - sample_batch("a", &[]), - ]; - let sorted = topological_sort(&batches); - let ids: Vec<&str> = sorted.iter().map(|b| b.id.as_str()).collect(); - assert_eq!(ids, vec!["a", "b", "c"]); - } - - #[test] - fn topological_sort_handles_missing_parent_ids() { - // When `parent_ids` references an id that isn't in the - // batches list (the API has dropped it for some reason), - // the sort skips it instead of panicking. - let batches = [sample_batch("only", &["nonexistent"])]; - let sorted = topological_sort(&batches); - assert_eq!(sorted.len(), 1); - assert_eq!(sorted[0].id, "only"); - } - - fn has_scope(groups: &[(String, Vec<&Batch>)], scope: &str) -> bool { - groups.iter().any(|(s, _)| s == scope) - } - - #[test] - fn group_by_scope_default_when_empty_scopes() { - let batches = [sample_batch("a", &[])]; - let refs: Vec<&Batch> = batches.iter().collect(); - let groups = group_by_scope(&refs); - assert_eq!(groups.len(), 1); - assert!(has_scope(&groups, "default")); - } - - #[test] - fn group_by_scope_assigns_to_each_listed_scope() { - // Matches Python: a multi-scope batch appears under each - // scope's group, not just the first. - let mut b = sample_batch("a", &[]); - b.scopes = vec!["foo".to_string(), "bar".to_string()]; - let batches = [b]; - let refs: Vec<&Batch> = batches.iter().collect(); - let groups = group_by_scope(&refs); - assert_eq!(groups.len(), 2); - assert!(has_scope(&groups, "foo")); - assert!(has_scope(&groups, "bar")); - } - - #[test] - fn batch_glyph_known_codes() { - // Pin a no-color theme so we exercise the icon mapping - // without coupling the assertions to the ANSI escape format. - let theme = Theme::new(false); - assert_eq!(batch_glyph(&theme, "running").icon, "●"); - assert_eq!(batch_glyph(&theme, "merged").icon, "✓"); - assert_eq!(batch_glyph(&theme, "failed").icon, "✗"); - // Two pairs that share an icon vs. a different icon — - // mirrors the Python `STATUS_STYLES` table, so a future - // table edit can't silently swap glyphs without updating - // this test. - assert_eq!( - batch_glyph(&theme, "waiting_for_previous_batches").icon, - "⏳" - ); - assert_eq!(batch_glyph(&theme, "waiting_for_batch").icon, "⏳"); - assert_eq!(batch_glyph(&theme, "waiting_for_requeue").icon, "↻"); - } - - #[test] - fn batch_glyph_unknown_falls_back() { - let theme = Theme::new(false); - assert_eq!(batch_glyph(&theme, "brand-new-status").icon, "?"); - } - - fn sample_batch(id: &str, parents: &[&str]) -> Batch { - Batch { - id: id.to_string(), - parent_ids: parents.iter().copied().map(String::from).collect(), - scopes: Vec::new(), - status: BatchStatus { - code: "running".to_string(), - }, - started_at: None, - estimated_merge_at: None, - checks_summary: ChecksSummary { - passed: 0, - total: 0, - }, - pull_requests: Vec::new(), - } - } - - #[tokio::test] - async fn run_json_passes_response_through_verbatim() { - // JSON mode is a passthrough — every field the server sends, - // including ones we don't render, must survive intact. - // `extra_field` here proves we don't reshape on the way out. - let server = MockServer::start().await; - let response = serde_json::json!({ - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {"default": []}, - "pause": null, - "extra_field": "preserved", - }); - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(200).set_body_json(response.clone())) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: true, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); - assert_eq!(parsed, response); - } - - #[tokio::test] - async fn run_human_renders_paused_queue() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": {"reason": "deploy freeze", "paused_at": "2026-01-01T00:00:00Z"}, - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("Merge Queue: owner/repo"), "got {stdout}"); - assert!(stdout.contains("Queue is paused"), "got {stdout}"); - assert!(stdout.contains("deploy freeze"), "got {stdout}"); - assert!(stdout.contains("Queue is empty"), "got {stdout}"); - } - - #[tokio::test] - async fn run_human_renders_empty_queue() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": null, - }))) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("Queue is empty"), "got {stdout}"); - } - - #[tokio::test] - async fn run_human_renders_batches_and_waiting_prs() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [{ - "id": "b1", - "name": "batch-1", - "status": {"code": "running"}, - "checks_summary": {"passed": 3, "total": 5}, - "started_at": "2026-01-01T00:00:00Z", - "estimated_merge_at": "2026-01-01T01:00:00Z", - "pull_requests": [ - { - "number": 42, - "title": "Add feature foo", - "url": "https://example.test/42", - "author": {"id": 1, "login": "alice"}, - "queued_at": "2026-01-01T00:00:00Z", - "priority_alias": "default", - "priority_rule_name": "default", - "labels": [], - "scopes": [], - }, - ], - "parent_ids": [], - }], - "waiting_pull_requests": [ - { - "number": 43, - "title": "Update deps", - "url": "https://example.test/43", - "author": {"id": 2, "login": "bob"}, - "queued_at": "2026-01-01T00:00:00Z", - "priority_alias": "high", - "priority_rule_name": "high", - "labels": [], - "scopes": [], - }, - ], - "scope_queues": {}, - "pause": null, - }))) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!(stdout.contains("Batches"), "got {stdout}"); - assert!(stdout.contains("running"), "got {stdout}"); - assert!(stdout.contains("checks 3/5"), "got {stdout}"); - assert!( - stdout.contains("#42 Add feature foo (alice)"), - "got {stdout}" - ); - assert!(stdout.contains("Waiting"), "got {stdout}"); - assert!(stdout.contains("#43"), "got {stdout}"); - assert!(stdout.contains("Update deps"), "got {stdout}"); - assert!(stdout.contains("bob"), "got {stdout}"); - assert!(stdout.contains("high"), "got {stdout}"); - } - - #[tokio::test] - async fn run_human_groups_batches_by_scope_when_multiple() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [ - { - "id": "b1", - "status": {"code": "running"}, - "checks_summary": {"passed": 0, "total": 0}, - "pull_requests": [], - "scopes": ["frontend"], - "parent_ids": [], - }, - { - "id": "b2", - "status": {"code": "preparing"}, - "checks_summary": {"passed": 0, "total": 0}, - "pull_requests": [], - "scopes": ["backend"], - "parent_ids": [], - }, - ], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": null, - }))) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - // Two scopes → each labelled by its own name (no - // generic "Batches" header). - assert!(stdout.contains("frontend"), "got {stdout}"); - assert!(stdout.contains("backend"), "got {stdout}"); - assert!(!stdout.contains("\nBatches\n"), "got {stdout}"); - } - - #[tokio::test] - async fn run_passes_branch_query_param() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .and(query_param("branch", "main")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [], - "waiting_pull_requests": [], - "scope_queues": {}, - "pause": null, - }))) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: Some("main"), - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn run_tolerates_missing_optional_fields() { - // The API has historically dropped optional fields entirely - // rather than serializing them as null. Deserialization - // must accept that — the response below has neither - // `pause` nor any of the per-batch optional timestamps. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/v1/repos/owner/repo/merge-queue/status")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "batches": [{ - "id": "b1", - "status": {"code": "running"}, - "checks_summary": {"passed": 0, "total": 0}, - "pull_requests": [], - }], - "waiting_pull_requests": [], - "scope_queues": {}, - }))) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - StatusOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - branch: None, - output_json: false, - }, - &mut cap.output, - ) - .await - .unwrap(); - } -} diff --git a/crates/mergify-queue/src/unpause.rs b/crates/mergify-queue/src/unpause.rs deleted file mode 100644 index b2ab124d..00000000 --- a/crates/mergify-queue/src/unpause.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! `mergify queue unpause` — resume the merge queue for a -//! repository. -//! -//! Sends a DELETE to ``/v1/repos//merge-queue/pause``. When the API -//! responds 404 the command prints "Queue is not currently paused" -//! and exits with `MERGIFY_API_ERROR` — matches Python's behavior. - -use std::io::Write; - -use mergify_core::CliError; -use mergify_core::CommandContext; -use mergify_core::DeleteOutcome; -use mergify_core::Output; - -pub struct UnpauseOptions<'a> { - pub repository: Option<&'a str>, - pub token: Option<&'a str>, - pub api_url: Option<&'a str>, -} - -/// Run the `queue unpause` command. -pub async fn run(opts: UnpauseOptions<'_>, output: &mut dyn Output) -> Result<(), CliError> { - let ctx = CommandContext::resolve(opts.repository, opts.token, opts.api_url)?; - - output.status(&format!( - "Unpausing merge queue for {repo}…", - repo = ctx.repository, - ))?; - - let client = ctx.mergify_client()?; - let path = format!("/v1/repos/{}/merge-queue/pause", ctx.repository); - - match client.delete_if_exists(&path).await? { - DeleteOutcome::Deleted => { - emit_unpaused(output)?; - Ok(()) - } - DeleteOutcome::NotFound => Err(CliError::MergifyApi( - "Queue is not currently paused".to_string(), - )), - } -} - -fn emit_unpaused(output: &mut dyn Output) -> std::io::Result<()> { - output.emit(&(), &mut |w: &mut dyn Write| { - writeln!(w, "Queue unpaused successfully.") - }) -} - -#[cfg(test)] -mod tests { - use mergify_test_support::Captured; - use wiremock::Mock; - use wiremock::MockServer; - use wiremock::ResponseTemplate; - use wiremock::matchers::header; - use wiremock::matchers::method; - use wiremock::matchers::path; - - use super::*; - - #[tokio::test] - async fn run_unpauses_on_2xx() { - let server = MockServer::start().await; - Mock::given(method("DELETE")) - .and(path("/v1/repos/owner/repo/merge-queue/pause")) - .and(header("Authorization", "Bearer t")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - run( - UnpauseOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - }, - &mut cap.output, - ) - .await - .unwrap(); - - let stdout = cap.stdout(); - assert!( - stdout.contains("Queue unpaused successfully."), - "got: {stdout:?}" - ); - } - - #[tokio::test] - async fn run_reports_not_currently_paused_on_404() { - let server = MockServer::start().await; - Mock::given(method("DELETE")) - .and(path("/v1/repos/owner/repo/merge-queue/pause")) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - - let mut cap = Captured::human(); - let api_url = server.uri(); - let err = run( - UnpauseOptions { - repository: Some("owner/repo"), - token: Some("t"), - api_url: Some(&api_url), - }, - &mut cap.output, - ) - .await - .unwrap_err(); - assert!(matches!(err, CliError::MergifyApi(_))); - assert!(err.to_string().contains("not currently paused")); - assert_eq!(err.exit_code(), mergify_core::ExitCode::MergifyApiError); - } -} diff --git a/crates/mergify-stack/Cargo.toml b/crates/mergify-stack/Cargo.toml deleted file mode 100644 index 487436b1..00000000 --- a/crates/mergify-stack/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "mergify-stack" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Native implementation of `mergify stack` subcommands." -publish = false - -[dependencies] -chrono = { workspace = true } -mergify-core = { path = "../mergify-core" } -# `stack push` live progress reuses the shared TTY/`NO_COLOR`-aware -# color palette instead of hand-rolling ANSI consts. -mergify-tui = { path = "../mergify-tui" } -regex = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tempfile = { workspace = true } -# `stack push` live progress: query the terminal width so each -# in-place row is truncated to one physical line (multi-line -# cursor-up redraw breaks if a row wraps). -terminal_size = { workspace = true } -# Measure CJK/emoji/wide glyphs at their real terminal column width so -# the in-place redraw's truncation never lets a row wrap (a wrap -# desyncs the cursor-up math and corrupts the live block). -unicode-width = { workspace = true } -# `rt` for `spawn_blocking` — runs the synchronous git fetch/push -# off the async executor so the `stack push` progress spinner keeps -# ticking smoothly across them. -tokio = { workspace = true } -# The native-stack fence swallows GitHub errors by design (an -# unavailable Stacks API must not fail a push); `-vv` is the only way -# to see why a registration was skipped. -tracing = { workspace = true } -url = { workspace = true } - -[dev-dependencies] -tokio = { workspace = true } -url = { workspace = true } -wiremock = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-stack/hooks/scripts/commit-msg.sh b/crates/mergify-stack/hooks/scripts/commit-msg.sh deleted file mode 100644 index 12bbe591..00000000 --- a/crates/mergify-stack/hooks/scripts/commit-msg.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Script -# This file is managed by mergify-cli and will be auto-upgraded. -# Do not modify - add custom logic to the wrapper file instead. -# -# Based on Gerrit Code Review 3.1.3 -# -# Part of Gerrit Code Review (https://www.gerritcodereview.com/) -# -# Copyright (C) 2009 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# avoid [[ which is not POSIX sh. -if test "$#" != 1 ; then - echo "$0 requires an argument." - exit 1 -fi - -if test ! -f "$1" ; then - echo "file does not exist: $1" - exit 1 -fi - -# $RANDOM will be undefined if not using bash, so don't use set -u -# $RANDOM is undefined in POSIX sh (dash), so include HEAD's SHA for entropy -# to prevent collisions when two commits have the same message in the same second -random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM; git rev-parse HEAD 2>/dev/null) | git hash-object --stdin) -dest="$1.tmp.${random}" - -trap 'rm -f "${dest}"' EXIT - -# Reuse a Change-Id from a prior commit on this branch with the same subject. -# Handles amends and reset-and-recreate cycles (where a branch is reset to main -# and the same stack is rebuilt from scratch, which would otherwise generate new -# Change-Ids and break PR tracking). -if ! grep -q "^Change-Id:" "$1"; then - subject=$(head -1 "$1") - branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - if test -n "$branch" && test "$branch" != "HEAD"; then - reuse_cid="" - for sha in $(git reflog "$branch" --format='%H' -n 50 2>/dev/null); do - s=$(git log -1 --format=%s "$sha" 2>/dev/null) - if test "$s" = "$subject"; then - # Only reuse from commits no longer in the branch (reset away). - # Skip commits still reachable from HEAD to avoid giving two - # active stack commits the same Change-Id. - git merge-base --is-ancestor "$sha" HEAD 2>/dev/null && continue - reuse_cid=$(git log -1 --format=%B "$sha" 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1 | sed 's/^Change-Id: I//') - test -n "$reuse_cid" && break - fi - done - if test -n "$reuse_cid"; then - random="$reuse_cid" - fi - fi -fi - -# cut everything from the scissor marker downwards, then strip comments/whitespace -if ! (sed '/^# -\{24\} >8 -\{24\}$/,$d' | git stripspace --strip-comments) < "$1" > "${dest}" ; then - echo "cannot strip comments from $1" - exit 1 -fi - -if test ! -s "${dest}" ; then - echo "file is empty: $1" - exit 1 -fi - -# Avoid the --in-place option which only appeared in Git 2.8 -# Avoid the --if-exists option which only appeared in Git 2.15 -if ! git -c trailer.ifexists=doNothing interpret-trailers \ - --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then - echo "cannot insert change-id line in $1" - exit 1 -fi - -if ! mv "${dest}" "$1" ; then - echo "cannot mv ${dest} to $1" - exit 1 -fi diff --git a/crates/mergify-stack/hooks/scripts/post-commit.sh b/crates/mergify-stack/hooks/scripts/post-commit.sh deleted file mode 100644 index e9856e3f..00000000 --- a/crates/mergify-stack/hooks/scripts/post-commit.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Script -# This file is managed by mergify-cli and will be auto-upgraded. -# Do not modify - add custom logic to the wrapper file instead. -# -# Safety net: if the commit-msg hook did not run or failed to add a Change-Id, -# this hook generates one and amends the commit to include it. - -# Prevent recursion from our own amend below -if test -n "$MERGIFY_POST_COMMIT_RUNNING"; then - exit 0 -fi - -# Read the commit message once -body=$(git log -1 --format=%B 2>/dev/null) - -# Check if the commit already has a Change-Id — nothing to do -if echo "$body" | grep -q "^Change-Id: I[0-9a-f]\{40\}$"; then - exit 0 -fi - -# Generate a Change-Id the same way the commit-msg hook does -random=$( (whoami ; hostname ; date; echo "$body" ; echo $RANDOM) | git hash-object --stdin) - -# Build the amended message: original message + blank line + Change-Id -msg_file=$(mktemp "${TMPDIR:-/tmp}/mergify-post-commit.XXXXXX") || { - echo "mergify: warning: could not create temporary file for Change-Id amend" >&2 - exit 0 -} -trap 'rm -f "$msg_file"' EXIT - -echo "$body" > "$msg_file" -printf '\nChange-Id: I%s\n' "$random" >> "$msg_file" - -# Amend the commit with --no-verify to avoid re-triggering hooks -export MERGIFY_POST_COMMIT_RUNNING=1 -if git commit --amend -F "$msg_file" --no-verify --allow-empty 2>/dev/null; then - echo "mergify: added missing Change-Id to commit" >&2 -else - echo "mergify: warning: could not add Change-Id to commit" >&2 -fi diff --git a/crates/mergify-stack/hooks/scripts/pre-push.sh b/crates/mergify-stack/hooks/scripts/pre-push.sh deleted file mode 100755 index 9a0bbaeb..00000000 --- a/crates/mergify-stack/hooks/scripts/pre-push.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Script -# This file is managed by mergify-cli and will be auto-upgraded. -# Do not modify - add custom logic to the wrapper file instead. -# -# Intercepts `git push` and suggests `mergify stack push` when on a stack branch. - -# Skip if called from `mergify stack push` (which invokes git push internally) -if test -n "$MERGIFY_STACK_PUSH"; then - exit 0 -fi - -# Only intercept if mergify stack is available -if ! command -v mergify >/dev/null 2>&1; then - exit 0 -fi - -branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) - -# Skip if not on a branch (detached HEAD) -if test -z "$branch"; then - exit 0 -fi - -# Skip if on main/master/develop (not a stack branch) -case "$branch" in - main|master|develop) exit 0 ;; -esac - -# Check if this branch has a Change-Id (indicator it's a stack branch) -base_branch=$(git config --get "branch.${branch}.merge" 2>/dev/null | sed 's|refs/heads/||') -if test -z "$base_branch"; then - exit 0 -fi - -# Check if any commit in the stack has a Change-Id -has_change_id=$(git log --format=%B "${base_branch}..HEAD" 2>/dev/null | grep -c "^Change-Id:") -if test "$has_change_id" -eq 0; then - exit 0 -fi - -echo "" -echo "This branch is managed by Mergify stacks." -echo " Use 'mergify stack push' instead of 'git push'." -echo "" -echo " 'mergify stack push' will:" -echo " - Create/update PRs for each commit" -echo " - Handle rebasing automatically" -echo " - Keep the stack in sync" -echo "" -echo " To proceed with 'git push' anyway, use: git push --no-verify" -echo "" -exit 1 diff --git a/crates/mergify-stack/hooks/scripts/prepare-commit-msg.sh b/crates/mergify-stack/hooks/scripts/prepare-commit-msg.sh deleted file mode 100644 index 810243cb..00000000 --- a/crates/mergify-stack/hooks/scripts/prepare-commit-msg.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Script -# This file is managed by mergify-cli and will be auto-upgraded. -# Do not modify - add custom logic to the wrapper file instead. -# -# Copyright © 2021-2026 Mergify SAS -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. - -# This hook preserves Change-Id during amend operations -# where the message is provided via -m or -F flags (which would otherwise lose -# this trailer). -# -# Arguments: -# $1 - Path to the commit message file -# $2 - Source of the commit message: message, template, merge, squash, or commit -# $3 - Commit SHA (only for squash or commit source) - -if test "$#" -lt 1; then - exit 0 -fi - -MSG_FILE="$1" -SOURCE="${2:-}" - -# If source is "commit" (from --amend or -c/-C), git already preserves the -# original message content including trailers, so we don't need to do anything. -# This hook is specifically for the case where -m or -F is used with --amend, -# which sets source to "message" and loses the original trailers. - -# Only act if we have a message file -if test ! -f "$MSG_FILE"; then - exit 0 -fi - -# Check if HEAD exists (not initial commit) -if ! git rev-parse --verify HEAD >/dev/null 2>&1; then - exit 0 -fi - -# Function to detect if this is an amend operation with -m flag -is_amend_with_m_flag() { - # Heuristic to detect amend: During --amend, git sets GIT_AUTHOR_DATE to preserve - # the original author date. This date should exactly match HEAD's author date. - # For a regular commit, GIT_AUTHOR_DATE is not set by git. - # We also check that source is "message" (set by git for both -m and -F flags). - if test "$SOURCE" != "message" || test -z "$GIT_AUTHOR_DATE"; then - return 1 - fi - - # Get HEAD's author date in the same format git uses internally (raw format) - # The raw format is: seconds-since-epoch timezone (e.g., "1234567890 +0000") - HEAD_AUTHOR_DATE_RAW=$(git log -1 --format=%ad --date=raw HEAD 2>/dev/null) - if test -z "$HEAD_AUTHOR_DATE_RAW"; then - return 1 - fi - - # Extract epoch from GIT_AUTHOR_DATE (handles various formats) - # During amend, GIT_AUTHOR_DATE is in format: "@epoch tz" (e.g., "@1234567890 +0000") - # Remove the @ prefix if present - GIT_AUTHOR_EPOCH=$(echo "$GIT_AUTHOR_DATE" | cut -d' ' -f1 | tr -d '@') - HEAD_AUTHOR_EPOCH=$(echo "$HEAD_AUTHOR_DATE_RAW" | cut -d' ' -f1) - - # If the epoch timestamps match, this is likely an amend operation. - # Additional check: the author date should be at least 2 seconds in the past. - # This prevents false positives when commits happen in quick succession - # (e.g., in automated tests or scripts) where timestamps might match by coincidence. - if test "$GIT_AUTHOR_EPOCH" != "$HEAD_AUTHOR_EPOCH"; then - return 1 - fi - - CURRENT_EPOCH=$(date +%s) - AGE=$((CURRENT_EPOCH - GIT_AUTHOR_EPOCH)) - if test "$AGE" -lt 2; then - return 1 - fi - - return 0 -} - -# Only proceed if this is an amend with -m flag -if ! is_amend_with_m_flag; then - exit 0 -fi - -# Track if we need to add a blank line before trailers -NEED_BLANK_LINE=false - -# Preserve Change-Id if missing from current message but present in HEAD -if ! grep -q "^Change-Id: I[0-9a-f]\{40\}$" "$MSG_FILE"; then - HEAD_CHANGEID=$(git log -1 --format=%B HEAD 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1) - if test -n "$HEAD_CHANGEID"; then - if test "$NEED_BLANK_LINE" = "false"; then - echo "" >> "$MSG_FILE" - NEED_BLANK_LINE=true - fi - echo "$HEAD_CHANGEID" >> "$MSG_FILE" - fi -fi diff --git a/crates/mergify-stack/hooks/wrappers/commit-msg b/crates/mergify-stack/hooks/wrappers/commit-msg deleted file mode 100644 index b9c900bc..00000000 --- a/crates/mergify-stack/hooks/wrappers/commit-msg +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Wrapper -# This file is managed by mergify-cli. You can add custom logic below the marker. - -MERGIFY_HOOKS_DIR="$(dirname "$0")/mergify-hooks" -if [ -f "$MERGIFY_HOOKS_DIR/commit-msg.sh" ]; then - "$MERGIFY_HOOKS_DIR/commit-msg.sh" "$@" || exit $? -fi - -# --- USER CUSTOM LOGIC BELOW --- -# Add your custom hook logic here (preserved during upgrades) diff --git a/crates/mergify-stack/hooks/wrappers/post-commit b/crates/mergify-stack/hooks/wrappers/post-commit deleted file mode 100644 index 324c7d60..00000000 --- a/crates/mergify-stack/hooks/wrappers/post-commit +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Wrapper -# This file is managed by mergify-cli. You can add custom logic below the marker. - -# Skip if this is a recursive call from the post-commit safety net amend -[ -n "$MERGIFY_POST_COMMIT_RUNNING" ] && exit 0 - -MERGIFY_HOOKS_DIR="$(dirname "$0")/mergify-hooks" -if [ -f "$MERGIFY_HOOKS_DIR/post-commit.sh" ]; then - "$MERGIFY_HOOKS_DIR/post-commit.sh" "$@" || exit $? -fi - -# --- USER CUSTOM LOGIC BELOW --- -# Add your custom hook logic here (preserved during upgrades) diff --git a/crates/mergify-stack/hooks/wrappers/pre-push b/crates/mergify-stack/hooks/wrappers/pre-push deleted file mode 100755 index 650f002f..00000000 --- a/crates/mergify-stack/hooks/wrappers/pre-push +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Wrapper -# This file is managed by mergify-cli. You can add custom logic below the marker. - -MERGIFY_HOOKS_DIR="$(dirname "$0")/mergify-hooks" -if [ -f "$MERGIFY_HOOKS_DIR/pre-push.sh" ]; then - "$MERGIFY_HOOKS_DIR/pre-push.sh" "$@" || exit $? -fi - -# --- USER CUSTOM LOGIC BELOW --- -# Add your custom hook logic here (preserved during upgrades) diff --git a/crates/mergify-stack/hooks/wrappers/prepare-commit-msg b/crates/mergify-stack/hooks/wrappers/prepare-commit-msg deleted file mode 100644 index 2e38d59d..00000000 --- a/crates/mergify-stack/hooks/wrappers/prepare-commit-msg +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Mergify CLI Hook Wrapper -# This file is managed by mergify-cli. You can add custom logic below the marker. - -MERGIFY_HOOKS_DIR="$(dirname "$0")/mergify-hooks" -if [ -f "$MERGIFY_HOOKS_DIR/prepare-commit-msg.sh" ]; then - "$MERGIFY_HOOKS_DIR/prepare-commit-msg.sh" "$@" || exit $? -fi - -# --- USER CUSTOM LOGIC BELOW --- -# Add your custom hook logic here (preserved during upgrades) diff --git a/crates/mergify-stack/src/approvals.rs b/crates/mergify-stack/src/approvals.rs deleted file mode 100644 index 54e3cdb1..00000000 --- a/crates/mergify-stack/src/approvals.rs +++ /dev/null @@ -1,559 +0,0 @@ -//! Decide whether `stack push` should rebase the local stack onto -//! trunk, or skip the rebase to avoid dismissing already-given -//! approvals. -//! -//! The trade-off: rebasing a stack force-pushes every branch in -//! it, which dismisses every "Approved" review on every PR in the -//! stack. That's painful when reviewers have already signed off -//! — but skipping the rebase is dangerous when the bottom of the -//! stack has a real merge conflict with trunk. This module -//! encodes the same decision Python makes: -//! -//! - `--skip-rebase` short-circuits to *don't rebase*. -//! - `--force-rebase` short-circuits to *rebase*. -//! - Otherwise: check approvals on every live PR; if any are -//! approved and the bottom PR doesn't have a real conflict, -//! skip the rebase; otherwise rebase. -//! -//! Ported from `mergify_cli/stack/approvals.py`. - -use std::time::Duration; - -use mergify_core::{CliError, HttpClient}; -use serde::Deserialize; -use serde_json::Value; - -use crate::changes::{Action, Changes}; - -const MERGEABLE_RETRY_DELAY: Duration = Duration::from_secs(1); -const CONFLICT_STATE: &str = "dirty"; - -/// Why [`decide_rebase`] reached its conclusion. Surfaced to the -/// CLI so the user can read the reason in the `stack push` -/// output ("Skipping rebase: PRs are approved"). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RebaseReason { - /// `--skip-rebase` was passed. - ExplicitSkip, - /// `--force-rebase` was passed. - Forced, - /// Some PRs are approved, but the bottom PR has a real merge - /// conflict with trunk — rebase wins. - ConflictOverride, - /// PRs are approved and there's no conflict → skip the - /// rebase to preserve the approvals. - SkippedForApprovals, - /// No approvals to preserve → rebase as usual. - NoApprovals, -} - -/// Result of [`decide_rebase`]: the boolean for the push code to -/// act on, the reason for logging, and the list of approved PR -/// payloads (carried as-is from the GitHub fetch so the caller can -/// surface PR numbers / titles in the rebase-skipped notice). -#[derive(Debug, Clone)] -pub struct RebaseDecision { - pub should_rebase: bool, - pub reason: RebaseReason, - pub approved_pulls: Vec, -} - -#[derive(Debug, Deserialize)] -struct Review { - state: String, - user: Option, -} - -#[derive(Debug, Deserialize)] -struct ReviewUser { - login: Option, -} - -#[derive(Debug, Deserialize)] -struct PullState { - mergeable_state: Option, - mergeable: Option, -} - -/// Is this PR currently approved? -/// -/// "Currently" means: take the latest non-comment, non-pending -/// review per reviewer (so a reviewer who once approved and then -/// requested changes counts as "requested changes"), and report -/// whether any of those latest reviews is APPROVED. -pub async fn pull_is_approved( - client: &HttpClient, - user: &str, - repo: &str, - pull_number: u64, -) -> Result { - let path = format!("/repos/{user}/{repo}/pulls/{pull_number}/reviews"); - let reviews: Vec = client.get(&path).await?; - - // Walk reviews in API order — GitHub returns them oldest-first - // — so the last write wins per reviewer. - let mut latest_by_reviewer: std::collections::HashMap = - std::collections::HashMap::new(); - for review in reviews { - if review.state == "COMMENTED" || review.state == "PENDING" { - continue; - } - if let Some(login) = review.user.and_then(|u| u.login) - && !login.is_empty() - { - latest_by_reviewer.insert(login, review.state); - } - } - Ok(latest_by_reviewer.values().any(|s| s == "APPROVED")) -} - -/// Fetch the set of approved PR numbers from `pulls`. Sequential -/// across PRs to stay under GitHub's secondary rate-limit on the -/// PR-reviews endpoint (same trade-off as [`crate::remote_changes`]). -pub async fn fetch_approved_pull_numbers( - client: &HttpClient, - user: &str, - repo: &str, - pulls: &[Value], -) -> Result, CliError> { - let mut approved = std::collections::HashSet::new(); - for pull in pulls { - let Some(number) = pull.get("number").and_then(Value::as_u64) else { - continue; - }; - if pull_is_approved(client, user, repo, number).await? { - approved.insert(number); - } - } - Ok(approved) -} - -/// Does the bottom PR have a real merge conflict with trunk? -/// -/// GitHub computes `mergeable` lazily — the first GET on a PR -/// often returns `null`. The Python implementation waits 1s and -/// retries once; we do the same. Any HTTP error coerces to -/// `false` (matches Python's `except httpx.HTTPError: return -/// False`) so a transient API hiccup can't force a surprise -/// rebase. -pub async fn bottom_pull_has_conflict( - client: &HttpClient, - user: &str, - repo: &str, - bottom_pull: Option<&Value>, -) -> bool { - let Some(pull) = bottom_pull else { - return false; - }; - let Some(number) = pull.get("number").and_then(Value::as_u64) else { - return false; - }; - let path = format!("/repos/{user}/{repo}/pulls/{number}"); - - let Ok(data) = client.get::(&path).await else { - return false; - }; - let data = if data.mergeable.is_none() { - tokio::time::sleep(MERGEABLE_RETRY_DELAY).await; - let Ok(retried) = client.get::(&path).await else { - return false; - }; - retried - } else { - data - }; - - data.mergeable_state.as_deref() == Some(CONFLICT_STATE) -} - -/// Make the rebase / no-rebase decision for one `stack push`. -/// -/// The full precedence: explicit flags first (so `--skip-rebase` / -/// `--force-rebase` are no-questions-asked), then the -/// approvals-vs-conflict trade-off described in the module docs. -pub async fn decide_rebase( - client: &HttpClient, - user: &str, - repo: &str, - planned_changes: &Changes, - skip_rebase: bool, - force_rebase: bool, -) -> Result { - if skip_rebase { - return Ok(RebaseDecision { - should_rebase: false, - reason: RebaseReason::ExplicitSkip, - approved_pulls: Vec::new(), - }); - } - if force_rebase { - return Ok(RebaseDecision { - should_rebase: true, - reason: RebaseReason::Forced, - approved_pulls: Vec::new(), - }); - } - - // All live PRs (skip-merged ones are already on trunk and - // their approvals don't matter for the push that's about - // to happen). - let stack_pulls: Vec = planned_changes - .locals - .iter() - .filter(|c| c.action != Action::SkipMerged) - .filter_map(|c| c.pull.clone()) - .collect(); - - let approved_numbers = fetch_approved_pull_numbers(client, user, repo, &stack_pulls).await?; - let approved_pulls: Vec = stack_pulls - .iter() - .filter(|p| { - p.get("number") - .and_then(Value::as_u64) - .is_some_and(|n| approved_numbers.contains(&n)) - }) - .cloned() - .collect(); - - // Bottom PR = first live (non-merged) change in the stack. - // `create` actions don't have a `pull` yet, so `bottom_pull` - // stays None and `bottom_pull_has_conflict` short-circuits - // to false — there's nothing on the server to be in conflict - // with anyway. - let bottom_pull = planned_changes - .locals - .iter() - .find(|c| c.action != Action::SkipMerged) - .and_then(|c| c.pull.as_ref()); - let has_conflict = bottom_pull_has_conflict(client, user, repo, bottom_pull).await; - - if !approved_pulls.is_empty() && has_conflict { - return Ok(RebaseDecision { - should_rebase: true, - reason: RebaseReason::ConflictOverride, - approved_pulls, - }); - } - if !approved_pulls.is_empty() { - return Ok(RebaseDecision { - should_rebase: false, - reason: RebaseReason::SkippedForApprovals, - approved_pulls, - }); - } - Ok(RebaseDecision { - should_rebase: true, - reason: RebaseReason::NoApprovals, - approved_pulls: Vec::new(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use mergify_core::{ApiFlavor, HttpClient}; - use serde_json::json; - use url::Url; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn make_client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).expect("valid url"), - "token", - ApiFlavor::GitHub, - ) - .expect("client") - } - - #[tokio::test] - async fn pull_is_approved_uses_latest_review_per_reviewer() { - // Same reviewer first APPROVED then CHANGES_REQUESTED — - // the latest wins. Without "latest per reviewer" semantics - // a stale APPROVE would falsely keep the PR in the - // approved set and the rebase skip would dismiss real - // change requests. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"state": "APPROVED", "user": {"login": "alice"}}, - {"state": "COMMENTED", "user": {"login": "bob"}}, - {"state": "CHANGES_REQUESTED", "user": {"login": "alice"}}, - ]))) - .mount(&server) - .await; - - let client = make_client(&server); - assert!(!pull_is_approved(&client, "o", "r", 1).await.unwrap()); - } - - #[tokio::test] - async fn pull_is_approved_returns_true_when_any_reviewer_has_latest_approval() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"state": "CHANGES_REQUESTED", "user": {"login": "alice"}}, - {"state": "APPROVED", "user": {"login": "bob"}}, - ]))) - .mount(&server) - .await; - - let client = make_client(&server); - assert!(pull_is_approved(&client, "o", "r", 1).await.unwrap()); - } - - #[tokio::test] - async fn bottom_pull_has_conflict_returns_true_on_dirty_state() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": false, - "mergeable_state": "dirty", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let pull = json!({"number": 1}); - assert!(bottom_pull_has_conflict(&client, "o", "r", Some(&pull)).await); - } - - #[tokio::test] - async fn bottom_pull_has_conflict_returns_false_for_clean_state() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": true, - "mergeable_state": "clean", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let pull = json!({"number": 1}); - assert!(!bottom_pull_has_conflict(&client, "o", "r", Some(&pull)).await); - } - - #[tokio::test] - async fn bottom_pull_has_conflict_short_circuits_when_no_bottom_pull() { - // The bottom-of-stack is a fresh `create` so there's no - // PR to query — no network call, no conflict. - let server = MockServer::start().await; - let client = make_client(&server); - assert!(!bottom_pull_has_conflict(&client, "o", "r", None).await); - } - - #[tokio::test] - async fn bottom_pull_has_conflict_returns_false_on_http_error() { - // GitHub flake → caller must not be coerced into rebasing - // when an approval would have skipped it. Python catches - // the error and returns False; we mirror that. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(500)) - .mount(&server) - .await; - - let client = make_client(&server); - let pull = json!({"number": 1}); - assert!(!bottom_pull_has_conflict(&client, "o", "r", Some(&pull)).await); - } - - fn local_change_with_pull(action: Action, pull: Option) -> crate::changes::LocalChange { - crate::changes::LocalChange { - commit_sha: "deadbeef".to_string(), - title: "t".to_string(), - message: "m".to_string(), - change_id: "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - action, - pull, - note: String::new(), - } - } - - #[tokio::test] - async fn decide_rebase_explicit_skip_short_circuits() { - let server = MockServer::start().await; - let client = make_client(&server); - let planned = Changes { - locals: vec![local_change_with_pull( - Action::Update, - Some(json!({"number": 1})), - )], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, true, false) - .await - .unwrap(); - assert!(!d.should_rebase); - assert_eq!(d.reason, RebaseReason::ExplicitSkip); - assert!(d.approved_pulls.is_empty()); - } - - #[tokio::test] - async fn decide_rebase_force_short_circuits_without_querying_github() { - // No mocks registered — if we queried we'd 404, so this - // also proves the short-circuit short-circuits. - let server = MockServer::start().await; - let client = make_client(&server); - let planned = Changes { - locals: vec![local_change_with_pull( - Action::Update, - Some(json!({"number": 1})), - )], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, false, true) - .await - .unwrap(); - assert!(d.should_rebase); - assert_eq!(d.reason, RebaseReason::Forced); - } - - #[tokio::test] - async fn decide_rebase_skips_when_any_pr_approved_and_no_conflict() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"state": "APPROVED", "user": {"login": "alice"}}, - ]))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": true, - "mergeable_state": "clean", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let planned = Changes { - locals: vec![local_change_with_pull( - Action::Update, - Some(json!({"number": 1})), - )], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, false, false) - .await - .unwrap(); - assert!(!d.should_rebase); - assert_eq!(d.reason, RebaseReason::SkippedForApprovals); - assert_eq!(d.approved_pulls.len(), 1); - } - - #[tokio::test] - async fn decide_rebase_overrides_approval_when_bottom_conflicts() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"state": "APPROVED", "user": {"login": "alice"}}, - ]))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": false, - "mergeable_state": "dirty", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let planned = Changes { - locals: vec![local_change_with_pull( - Action::Update, - Some(json!({"number": 1})), - )], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, false, false) - .await - .unwrap(); - assert!(d.should_rebase); - assert_eq!(d.reason, RebaseReason::ConflictOverride); - } - - #[tokio::test] - async fn decide_rebase_rebases_when_no_approvals() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": true, - "mergeable_state": "clean", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let planned = Changes { - locals: vec![local_change_with_pull( - Action::Update, - Some(json!({"number": 1})), - )], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, false, false) - .await - .unwrap(); - assert!(d.should_rebase); - assert_eq!(d.reason, RebaseReason::NoApprovals); - assert!(d.approved_pulls.is_empty()); - } - - #[tokio::test] - async fn decide_rebase_excludes_skip_merged_pulls_from_approval_set() { - // A merged PR's approvals don't matter — its commit is - // already on trunk and won't be force-pushed. The Python - // implementation filters skip-merged out of the approval - // check; if we ever regress, a stack with a merged - // approved PR followed by a fresh-create-with-no-PR - // would falsely skip the rebase. - let server = MockServer::start().await; - // Only PR 2 should be queried — PR 1 is skip-merged. - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/2/reviews")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/repos/o/r/pulls/2")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "mergeable": true, - "mergeable_state": "clean", - }))) - .mount(&server) - .await; - - let client = make_client(&server); - let planned = Changes { - locals: vec![ - local_change_with_pull(Action::SkipMerged, Some(json!({"number": 1}))), - local_change_with_pull(Action::Update, Some(json!({"number": 2}))), - ], - orphans: Vec::new(), - }; - let d = decide_rebase(&client, "o", "r", &planned, false, false) - .await - .unwrap(); - // No approvals on the live PR, so rebase happens. - assert!(d.should_rebase); - assert_eq!(d.reason, RebaseReason::NoApprovals); - } -} diff --git a/crates/mergify-stack/src/change_id.rs b/crates/mergify-stack/src/change_id.rs deleted file mode 100644 index 10aec832..00000000 --- a/crates/mergify-stack/src/change_id.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! `Change-Id` parsing helpers. -//! -//! Mergify tags each stacked commit with a `Change-Id: I<40-hex>` -//! trailer in the commit message body — that ID is what pins a -//! local commit to its remote branch + PR across rewrites. The -//! Python code mirrored by this module ships three flavours of -//! the same value: -//! -//! - the full I-prefixed 40-hex Change-Id (`I000…aaa`) -//! - a "prefix" that may be partial (typed by the user on the CLI) -//! - an 8-hex "short" form embedded in the new-style branch name -//! suffix `…--abcd1234` -//! -//! Extraction from a commit body uses a deliberately permissive -//! regex (`[0-9a-z]` not `[0-9a-f]`) so a malformed trailer still -//! surfaces and `is_full` can flag it; validation is strict. - -use std::sync::LazyLock; - -use regex::Regex; - -/// Permissive pattern used when *finding* a Change-Id trailer in a -/// commit body. Stays loose (`[0-9a-z]`, not `[0-9a-f]`) so a -/// malformed `Change-Id: Ixxxg…` still gets pulled out, lets the -/// caller see what was written instead of silently returning -/// `None`. -const CHANGEID_LOOSE_PATTERN: &str = r"I[0-9a-z]{40}"; - -/// Strict full-form pattern: I + 40 lowercase hex chars. -const CHANGEID_FULL_PATTERN: &str = r"^I[0-9a-f]{40}$"; - -/// Suffix pattern: `--<8 hex>` at end of a string. Used to peel -/// the short Change-Id out of new-style branch segments. -const SHORT_CHANGEID_PATTERN: &str = r"--([0-9a-f]{8})$"; - -static CHANGEID_TRAILER_RE: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"Change-Id: ({CHANGEID_LOOSE_PATTERN})")).unwrap()); - -static CHANGEID_FULL_RE: LazyLock = - LazyLock::new(|| Regex::new(CHANGEID_FULL_PATTERN).unwrap()); - -static SHORT_CHANGEID_RE: LazyLock = - LazyLock::new(|| Regex::new(SHORT_CHANGEID_PATTERN).unwrap()); - -/// Branch-suffix pattern: `/I<40-hex>` at end of a branch name — -/// the legacy stack-branch naming scheme. Used by -/// `stack checkout` to strip the suffix from the user-supplied -/// stack name when the user passes a full leaf-branch ref. -static BRANCH_SUFFIX_RE: LazyLock = - LazyLock::new(|| Regex::new(&format!(r"/{CHANGEID_LOOSE_PATTERN}$")).unwrap()); - -/// Strip a trailing Change-Id segment from a stack branch name, -/// in either naming scheme: the legacy `/Ixxxx…` suffix or the -/// current `/--xxxxxxxx` one. Returns the input unchanged -/// when the last segment isn't a Change-Id segment — including -/// when it's the *only* segment, since a branch name has to keep -/// at least one. -/// -/// This is what lets `stack checkout` accept a leaf branch ref -/// (or a PR head ref) pasted verbatim and resolve it to the stack -/// stem the rest of the stack hangs off. -#[must_use] -pub fn strip_branch_suffix(name: &str) -> String { - // The legacy form goes through the loose pattern on purpose — - // a malformed `I<40 non-hex>` segment is still a suffix the - // user meant to paste, and dropping it beats searching for a - // stack branch that can't exist. - let stripped = BRANCH_SUFFIX_RE.replace(name, ""); - if stripped != name { - return stripped.into_owned(); - } - match name.rsplit_once('/') { - Some((stem, last)) if SHORT_CHANGEID_RE.is_match(last) => stem.to_string(), - _ => name.to_string(), - } -} - -/// Return `true` if `value` is a strict, full-form Change-Id -/// (`I` + 40 lowercase hex chars). -#[must_use] -pub fn is_full(value: &str) -> bool { - CHANGEID_FULL_RE.is_match(value) -} - -/// Return `true` if `prefix` could be the start of a Change-Id. -/// Requires at least two characters (the leading `I` plus one hex -/// digit) so a bare `"I"` doesn't qualify. Used by the CLI to let -/// users target a stack commit by typing only the first few -/// characters of its Change-Id. -#[must_use] -pub fn is_prefix(prefix: &str) -> bool { - let bytes = prefix.as_bytes(); - bytes.len() >= 2 - && bytes[0] == b'I' - && bytes[1..] - .iter() - .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)) -} - -/// Extract the *last* `Change-Id: …` trailer from a commit -/// message body. Returns `None` if no trailer is present. The -/// returned value is the raw match — callers that want strictness -/// should pass it through [`is_full`]. -/// -/// "Last" matters: Mergify rewrites tend to append a new trailer -/// rather than replace, and the most recent one wins. -#[must_use] -pub fn extract_from_message(message: &str) -> Option<&str> { - CHANGEID_TRAILER_RE - .captures_iter(message) - .last() - .map(|cap| cap.get(1).unwrap().as_str()) -} - -/// Extract a Change-Id from the last segment of a stack branch -/// name. Returns either: -/// -/// - the **full** Change-Id (old-style branch, the segment *is* -/// a Change-Id, 41 chars `I…`), or -/// - the **short** 8-hex tail (new-style branch, segment ends in -/// `--xxxxxxxx`). -/// -/// Callers need both because remote branches in the wild can be -/// either shape. The matching layer ([`crate::change_id`] -/// callers in `changes.py`) handles the cross-format pairing. -#[must_use] -pub fn extract_from_branch_segment(segment: &str) -> Option<&str> { - if is_full(segment) { - return Some(segment); - } - SHORT_CHANGEID_RE - .captures(segment) - .map(|cap| cap.get(1).unwrap().as_str()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_full_accepts_well_formed_id() { - let id = "I0123456789abcdef0123456789abcdef01234567"; - assert!(is_full(id)); - } - - #[test] - fn is_full_rejects_uppercase_hex_and_wrong_length_and_wrong_prefix() { - // Hex chars must be lowercase. - assert!(!is_full("I0123456789ABCDEF0123456789abcdef01234567")); - // Off-by-one — 39 hex. - assert!(!is_full("I0123456789abcdef0123456789abcdef0123456")); - // Off-by-one — 41 hex. - assert!(!is_full("I0123456789abcdef0123456789abcdef012345678")); - // Missing leading I. - assert!(!is_full("0123456789abcdef0123456789abcdef01234567")); - // Empty. - assert!(!is_full("")); - } - - #[test] - fn is_prefix_accepts_partial_id_with_at_least_one_hex() { - assert!(is_prefix("Ia")); - assert!(is_prefix("I0")); - assert!(is_prefix("Iabc123")); - } - - #[test] - fn is_prefix_rejects_bare_i_and_non_hex_chars() { - // Bare "I" — Python requires at least two chars so a stray - // capital I doesn't accidentally match. - assert!(!is_prefix("I")); - // Non-hex after the I. - assert!(!is_prefix("Ig")); - assert!(!is_prefix("Iaz")); - // Empty. - assert!(!is_prefix("")); - // No leading I. - assert!(!is_prefix("abc")); - } - - #[test] - fn extract_from_message_returns_last_trailer_when_multiple() { - // Commits get rewritten and amends sometimes append a new - // trailer instead of replacing — the rightmost one wins. - let msg = "subject\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nChange-Id: Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n"; - assert_eq!( - extract_from_message(msg), - Some("Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - ); - } - - #[test] - fn extract_from_message_returns_none_when_missing() { - assert_eq!(extract_from_message("subject\n\nbody"), None); - } - - #[test] - fn extract_from_message_uses_loose_pattern_so_callers_can_flag_typos() { - // `g` is outside [0-9a-f] but inside [0-9a-z]. The - // extractor still surfaces it so the caller can run it - // through `is_full` and report "looks like a Change-Id but - // isn't valid hex". Silently dropping it would make the - // error message useless. - let msg = "Change-Id: Igggggggggggggggggggggggggggggggggggggggg"; - let extracted = extract_from_message(msg).expect("loose match"); - assert!(!is_full(extracted)); - } - - #[test] - fn extract_from_branch_segment_returns_full_id_for_old_style() { - let id = "I0123456789abcdef0123456789abcdef01234567"; - assert_eq!(extract_from_branch_segment(id), Some(id)); - } - - #[test] - fn extract_from_branch_segment_returns_short_tail_for_new_style() { - // `mergify stack push` slugifies the commit title and - // appends `--<8 hex>` for human readability. - assert_eq!( - extract_from_branch_segment("my-slug--abcd1234"), - Some("abcd1234"), - ); - } - - #[test] - fn extract_from_branch_segment_returns_none_for_unrelated_segments() { - assert_eq!(extract_from_branch_segment(""), None); - assert_eq!(extract_from_branch_segment("plain-segment"), None); - // Has the right shape but wrong length on the short tail. - assert_eq!(extract_from_branch_segment("slug--abcd123"), None); - assert_eq!(extract_from_branch_segment("slug--abcd12345"), None); - } - - #[test] - fn strip_branch_suffix_removes_legacy_full_changeid_segment() { - assert_eq!( - strip_branch_suffix("stack/a/my-branch/I0123456789abcdef0123456789abcdef01234567"), - "stack/a/my-branch", - ); - } - - #[test] - fn strip_branch_suffix_removes_new_style_short_changeid_segment() { - // `stack push` names branches `/--<8 hex>`; - // pasting such a leaf ref into `stack checkout` must - // resolve to the stack stem, not to the leaf itself. - assert_eq!( - strip_branch_suffix("stack/a/my-branch/feat-a--abcd1234"), - "stack/a/my-branch", - ); - } - - #[test] - fn strip_branch_suffix_leaves_plain_branches_untouched() { - assert_eq!( - strip_branch_suffix("stack/a/my-branch"), - "stack/a/my-branch" - ); - assert_eq!(strip_branch_suffix("my-feature"), "my-feature"); - // A single segment that *is* a change-id segment is the - // whole branch name — nothing left to strip down to. - assert_eq!(strip_branch_suffix("feat-a--abcd1234"), "feat-a--abcd1234"); - } -} diff --git a/crates/mergify-stack/src/change_type.rs b/crates/mergify-stack/src/change_type.rs deleted file mode 100644 index 0c8be572..00000000 --- a/crates/mergify-stack/src/change_type.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Classify a force-pushed PR head as a *rebase-only* update or a -//! *content* change. -//! -//! Used by `stack push` when building the revision-history table: -//! after the local stack has been rebased onto trunk, every -//! updated PR needs a label so reviewers can distinguish "I just -//! caught up with main" from "I edited the diff." The decision is -//! made by comparing `git patch-id --stable` outputs of the old -//! and new commit SHAs — `patch-id` hashes the diff with line -//! numbers and whitespace normalised, so a pure rebase produces -//! the same id while any real content edit produces a different -//! one. -//! -//! Ported from `mergify_cli/stack/push.py::detect_change_type` -//! plus the [`fetch_old_pr_heads`] helper that materialises the -//! `refs/pull//head` refs locally before the patch-id -//! comparison runs (without this step the old SHA would already -//! have been clobbered by the force-push). The Python `_internal` -//! bridge that consumes this module lands in a follow-up — this -//! commit ports the leaf so the eventual native `stack push` has -//! it ready. - -use std::io::Write; -use std::path::Path; -use std::process::Stdio; - -use crate::git::{git_cmd, run_git_capture, run_git_silent}; - -use mergify_core::CliError; -use serde::{Deserialize, Serialize}; - -/// Result of [`detect_change_type`] plus the synthetic `Initial` -/// tag used for the first revision-history row (which has no -/// "old" SHA to compare against). -/// -/// Wire format matches the Python `Literal["initial", "rebase", -/// "content", "unknown"]`: lowercase strings, no variant -/// prefix — JSON-shape stability is a contract with the -/// revision-history comment parser. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ChangeType { - Initial, - Rebase, - Content, - Unknown, -} - -impl ChangeType { - /// Lowercase wire form (`"initial"` / `"rebase"` / `"content"` - /// / `"unknown"`). Used when emitting the value to anything - /// that doesn't go through serde (logs, CLI output, the - /// revision-history comment body). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Initial => "initial", - Self::Rebase => "rebase", - Self::Content => "content", - Self::Unknown => "unknown", - } - } - - /// Inverse of [`Self::as_str`] with the same fallback semantics - /// as Python's `_coerce_change_type`: anything that doesn't - /// match a known variant is coerced to [`Self::Unknown`] so a - /// legacy or hand-edited comment can't crash the parser. - #[must_use] - pub fn from_str_lossy(value: &str) -> Self { - match value { - "initial" => Self::Initial, - "rebase" => Self::Rebase, - "content" => Self::Content, - _ => Self::Unknown, - } - } -} - -/// `git patch-id --stable` on the diff of `sha`. -/// -/// Returns the patch-id half of `git patch-id`'s output (the -/// command prints `" "`). Errors propagate -/// as `CliError::Generic` so callers can decide whether to fall -/// back to [`ChangeType::Unknown`] or surface the failure. -pub fn git_patch_id(repo_dir: Option<&Path>, sha: &str) -> Result { - let diff = run_git_capture(repo_dir, &["show", sha])?; - - let mut child = git_cmd(repo_dir) - .args(["patch-id", "--stable"]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| CliError::Generic(format!("failed to spawn `git patch-id`: {e}")))?; - - { - let stdin = child - .stdin - .as_mut() - .ok_or_else(|| CliError::Generic("failed to open `git patch-id` stdin".to_string()))?; - stdin - .write_all(diff.as_bytes()) - .map_err(|e| CliError::Generic(format!("failed to write to `git patch-id`: {e}")))?; - } - - let output = child - .wait_with_output() - .map_err(|e| CliError::Generic(format!("failed to read `git patch-id` output: {e}")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(CliError::Generic(if stderr.is_empty() { - format!("`git patch-id` failed for {sha}") - } else { - stderr - })); - } - - let stdout = String::from_utf8(output.stdout) - .map_err(|e| CliError::Generic(format!("`git patch-id` output is not UTF-8: {e}")))?; - stdout - .split_whitespace() - .next() - .map(str::to_owned) - .ok_or_else(|| CliError::Generic(format!("`git patch-id` printed no patch-id for {sha}"))) -} - -/// Compare patch-ids of `old_sha` and `new_sha` to classify a -/// force-push. -/// -/// Any error — missing commit, non-UTF-8 output, empty patch-id -/// — coerces to [`ChangeType::Unknown`]. Python matches the same -/// `(CommandError, IndexError, UnicodeDecodeError)` net so a flaky -/// classification can't take down a `stack push`. -#[must_use] -pub fn detect_change_type(repo_dir: Option<&Path>, old_sha: &str, new_sha: &str) -> ChangeType { - let Ok(old) = git_patch_id(repo_dir, old_sha) else { - return ChangeType::Unknown; - }; - let Ok(new) = git_patch_id(repo_dir, new_sha) else { - return ChangeType::Unknown; - }; - if old == new { - ChangeType::Rebase - } else { - ChangeType::Content - } -} - -/// `git fetch refs/pull//head …` so the pre-force-push -/// PR heads exist locally as ref-counted commits. -/// -/// Must be called **before** `git push --force-with-lease` -/// overwrites the remote heads — once that happens, the old SHA -/// is reachable only via these `refs/pull/*/head` refs. -/// -/// Empty `pr_numbers` is a no-op (mirrors the Python early-return -/// so callers don't need to guard). -pub fn fetch_old_pr_heads( - repo_dir: Option<&Path>, - remote: &str, - pr_numbers: &[u64], -) -> Result<(), CliError> { - if pr_numbers.is_empty() { - return Ok(()); - } - let mut owned: Vec = vec!["fetch".into(), remote.into()]; - for n in pr_numbers { - owned.push(format!("refs/pull/{n}/head")); - } - let args: Vec<&str> = owned.iter().map(String::as_str).collect(); - run_git_silent(repo_dir, &args) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = TempDir::new().expect("tempdir"); - let path = dir.path(); - run(path, &["init", "-q", "-b", "main"]); - run(path, &["config", "user.email", "test@example.com"]); - run(path, &["config", "user.name", "Test"]); - run(path, &["config", "commit.gpgsign", "false"]); - dir - } - - fn run(path: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .expect("git"); - assert!(status.success(), "git {args:?} failed"); - } - - fn write_and_commit(path: &Path, file: &str, contents: &str, msg: &str) -> String { - std::fs::write(path.join(file), contents).expect("write"); - run(path, &["add", file]); - run(path, &["commit", "-q", "-m", msg]); - run_git_capture(Some(path), &["rev-parse", "HEAD"]) - .unwrap() - .trim() - .to_string() - } - - #[test] - fn wire_form_round_trips() { - for ct in [ - ChangeType::Initial, - ChangeType::Rebase, - ChangeType::Content, - ChangeType::Unknown, - ] { - assert_eq!(ChangeType::from_str_lossy(ct.as_str()), ct); - } - // Anything unknown coerces to Unknown — guards against - // legacy / hand-edited comments crashing the parser. - assert_eq!(ChangeType::from_str_lossy("garbage"), ChangeType::Unknown); - assert_eq!(ChangeType::from_str_lossy(""), ChangeType::Unknown); - } - - #[test] - fn serde_uses_lowercase_strings() { - // Wire compatibility with the Python `Literal[...]` shape: - // the revision-history comment embeds the value verbatim, - // so any drift would break parsing of historic comments. - assert_eq!( - serde_json::to_string(&ChangeType::Rebase).unwrap(), - "\"rebase\"" - ); - assert_eq!( - serde_json::from_str::("\"content\"").unwrap(), - ChangeType::Content - ); - } - - #[test] - fn detect_rebase_when_diff_unchanged() { - let dir = init_repo(); - let path = dir.path(); - write_and_commit(path, "a", "1\n", "base"); - let old = write_and_commit(path, "b", "x\n", "feat: add b"); - - // Soft-reset and re-commit the same staged tree with a - // different message — same diff, new SHA. That's the - // "pure rebase" signal patch-id is built to detect. - run(path, &["reset", "--soft", "HEAD~1"]); - run(path, &["commit", "-q", "-m", "feat: add b (rebased)"]); - let new = run_git_capture(Some(path), &["rev-parse", "HEAD"]) - .unwrap() - .trim() - .to_string(); - assert_ne!(old, new, "soft-reset+commit must produce a new SHA"); - - assert_eq!( - detect_change_type(Some(path), &old, &new), - ChangeType::Rebase, - ); - } - - #[test] - fn detect_content_when_diff_differs() { - let dir = init_repo(); - let path = dir.path(); - write_and_commit(path, "a", "1\n", "base"); - let old = write_and_commit(path, "b", "x\n", "feat: add b"); - // Amend the same commit to change its content; patch-ids - // must diverge. - std::fs::write(path.join("b"), "x\ny\n").unwrap(); - run(path, &["add", "b"]); - run(path, &["commit", "-q", "--amend", "--no-edit"]); - let new = run_git_capture(Some(path), &["rev-parse", "HEAD"]) - .unwrap() - .trim() - .to_string(); - - assert_eq!( - detect_change_type(Some(path), &old, &new), - ChangeType::Content, - ); - } - - #[test] - fn detect_unknown_when_sha_missing() { - let dir = init_repo(); - let path = dir.path(); - write_and_commit(path, "a", "1\n", "base"); - // Bogus SHAs — both `git show` calls fail, so the - // classifier must fall back to Unknown rather than - // propagating the error. - assert_eq!( - detect_change_type(Some(path), "deadbeef", "cafef00d"), - ChangeType::Unknown, - ); - } - - #[test] - fn fetch_old_pr_heads_empty_is_noop() { - let dir = init_repo(); - // No fetch invocation, so an unconfigured remote can't - // make this fail. - fetch_old_pr_heads(Some(dir.path()), "origin", &[]).expect("noop"); - } -} diff --git a/crates/mergify-stack/src/changes.rs b/crates/mergify-stack/src/changes.rs deleted file mode 100644 index 52fbdad2..00000000 --- a/crates/mergify-stack/src/changes.rs +++ /dev/null @@ -1,471 +0,0 @@ -//! Full stack-change classifier. -//! -//! Walks the local stack commits, matches each to a remote PR by -//! `Change-Id`, and produces a `LocalChange` per commit tagged -//! with the same `Action` enum the Python `get_changes` returns. -//! `stack list`, `stack open`, and (eventually) `stack push` all -//! consume this; the merged-vs-remaining subset that `stack sync` -//! uses lives in [`crate::sync_status`] for the same reason — -//! sync only needs the bucket, not the full classifier. - -use mergify_core::CliError; -use serde::Serialize; -use serde_json::Value; - -use crate::local_commits::LocalCommit; -use crate::remote_changes::RemoteChange; - -/// Action attached to each local commit. Matches Python's -/// `ActionT` literal union, including the two orchestrator-only -/// overrides (`SkipCreate`, `SkipNextOnly`) that [`classify`] -/// never produces but [`plan`] can. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum Action { - /// Commit was merged via its PR (head SHA matches). The PR's - /// commit is already in trunk; future syncs drop it. - SkipMerged, - /// PR head SHA matches the local commit — nothing to push. - SkipUpToDate, - /// Commit has no PR yet — would be created on the next push. - Create, - /// Commit has an open PR whose head differs — would be updated. - Update, - /// `--only-update-existing-pulls` flag turns `Create` into - /// `SkipCreate` so the planner can surface "would-be-created" - /// PRs without actually opening them. - SkipCreate, - /// `--next-only` flag turns every change after the first into - /// `SkipNextOnly` so only the bottom of the stack gets pushed. - /// Pull is preserved when one already existed — the orchestrator - /// just doesn't touch it this run. - SkipNextOnly, -} - -/// One commit's classification result. -#[derive(Debug, Clone, Serialize)] -pub struct LocalChange { - pub commit_sha: String, - pub title: String, - pub message: String, - pub change_id: String, - pub action: Action, - /// Matched PR payload from GitHub when one exists. Carried - /// through as `serde_json::Value` so renderers can read any - /// PR field without forcing a typed schema. - pub pull: Option, - pub note: String, -} - -/// Output of the classifier. `orphans` are remote changes that -/// don't match any local commit (open PRs whose Change-Id isn't -/// in the local stack — typically the user dropped a commit -/// locally without closing the PR). -#[derive(Debug, Clone, Serialize)] -pub struct Changes { - pub locals: Vec, - pub orphans: Vec, -} - -/// Classify every local commit. `remote_changes` is consumed — -/// each matched pull is removed so we can identify orphans at -/// the end. -pub fn classify( - local_commits: &[LocalCommit], - mut remote_changes: Vec, -) -> Result { - let mut locals = Vec::with_capacity(local_commits.len()); - - for local in local_commits { - let pull = pop_matching(&mut remote_changes, &local.change_id); - - // Pre-emptively peek the merge state — it affects the - // action choice in two places. - let merged = pull - .as_ref() - .and_then(|p| p.pull.get("merged_at")) - .is_some_and(|v| !v.is_null()); - let head_sha = pull - .as_ref() - .and_then(|p| p.pull.pointer("/head/sha")) - .and_then(Value::as_str); - - let action = if pull.is_none() { - Action::Create - } else if merged && head_sha == Some(local.commit_sha.as_str()) { - Action::SkipMerged - } else if merged { - // Commit was amended after its PR merged — re-create. - Action::Create - } else if head_sha == Some(local.commit_sha.as_str()) { - Action::SkipUpToDate - } else { - Action::Update - }; - - // If the pull was merged-but-amended we treat it as no-PR - // for downstream consumers, mirroring Python. - let pull_for_output = if matches!(action, Action::Create) && pull.is_some() { - None - } else { - pull.map(|p| p.pull) - }; - - locals.push(LocalChange { - commit_sha: local.commit_sha.clone(), - title: local.title.clone(), - message: local.message.clone(), - change_id: local.change_id.clone(), - action, - pull: pull_for_output, - note: local.note.clone(), - }); - } - - let orphans: Vec = remote_changes - .into_iter() - .filter_map(|c| { - let state = c.pull.get("state").and_then(Value::as_str); - (state == Some("open")).then_some(c.pull) - }) - .collect(); - - Ok(Changes { locals, orphans }) -} - -/// Same cross-prefix Change-Id matching `sync_status` uses; -/// duplicated here so the two classifiers stay independent. -fn pop_matching( - remote_changes: &mut Vec, - local_changeid: &str, -) -> Option { - if let Some(idx) = remote_changes - .iter() - .position(|c| c.change_id == local_changeid) - { - return Some(remote_changes.swap_remove(idx)); - } - let local_hex = local_changeid.strip_prefix('I').unwrap_or(local_changeid); - let idx = remote_changes.iter().position(|c| { - let remote_hex = c.change_id.strip_prefix('I').unwrap_or(&c.change_id); - remote_hex.starts_with(local_hex) || local_hex.starts_with(remote_hex) - })?; - Some(remote_changes.swap_remove(idx)) -} - -/// First 7 characters of a SHA — the short form every log line -/// uses. -fn short_sha(sha: &str) -> String { - sha.chars().take(7).collect() -} - -/// Plain-text log line describing one planned change. `dry_run` -/// selects the would-be wording ("to create") shown in the plan -/// preview vs the past-tense ("created") shown after the push -/// lands. `dest_branch` is the branch the PR will live on — used -/// as the URL placeholder until the PR exists. -/// -/// Ported from Python `LocalChange.get_log_from_local_change`, -/// minus the Rich colour tags: this CLI prints plain lines so log -/// scrapers don't have to strip ANSI (see `render_stack_list_text`). -#[must_use] -pub fn format_local_change_log( - change: &LocalChange, - dest_branch: &str, - dry_run: bool, - create_as_draft: bool, -) -> String { - let url = match change.pull.as_ref() { - Some(pull) => pull - .get("html_url") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(), - None => format!("<{dest_branch}>"), - }; - - let mut flags = String::new(); - let draft = change - .pull - .as_ref() - .and_then(|p| p.get("draft")) - .and_then(Value::as_bool) - .unwrap_or(false); - if draft || (matches!(change.action, Action::Create) && create_as_draft) { - flags.push_str(" (draft)"); - } - - let commit_short = short_sha(&change.commit_sha); - let (action, commit_info) = match change.action { - Action::Create => ( - if dry_run { "to create" } else { "created" }.to_string(), - commit_short, - ), - Action::Update => { - let head = change - .pull - .as_ref() - .and_then(|p| p.pointer("/head/sha")) - .and_then(Value::as_str) - .map(short_sha) - .unwrap_or_default(); - ( - if dry_run { "to update" } else { "updated" }.to_string(), - format!("{head} -> {commit_short}"), - ) - } - Action::SkipCreate => ( - "skip, --only-update-existing-pulls".to_string(), - commit_short, - ), - Action::SkipMerged => { - flags.push_str(" (merged)"); - // The merge commit's short SHA when the PR really merged, - // else the local commit's. (Python sliced `[7:]` here — - // a typo; the short SHA is the first 7 chars.) - let info = change - .pull - .as_ref() - .filter(|p| p.get("merged_at").is_some_and(|v| !v.is_null())) - .and_then(|p| p.get("merge_commit_sha")) - .and_then(Value::as_str) - .map(short_sha) - .unwrap_or(commit_short); - ("merged".to_string(), info) - } - Action::SkipNextOnly => ("skip, --next-only".to_string(), commit_short), - Action::SkipUpToDate => ("up-to-date".to_string(), commit_short), - }; - - format!( - "* [{action}] '{commit_info} - {title}{flags} {url}", - title = change.title, - ) -} - -/// Plain-text log line for an orphan PR (one whose Change-Id left -/// the local stack) being deleted. `dry_run` toggles "to delete" -/// vs "deleted". Ported from -/// `OrphanChange.get_log_from_orphan_change`. -#[must_use] -pub fn format_orphan_change_log(pull: &Value, dry_run: bool) -> String { - let action = if dry_run { "to delete" } else { "deleted" }; - let title = pull - .get("title") - .and_then(Value::as_str) - .unwrap_or(""); - let url = pull - .get("html_url") - .and_then(Value::as_str) - .unwrap_or(""); - let sha = pull - .pointer("/head/sha") - .and_then(Value::as_str) - .map_or_else(|| "".to_string(), short_sha); - format!("* [{action}] '{sha} - {title} {url}") -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn local(sha: &str, change_id: &str, title: &str) -> LocalCommit { - LocalCommit { - commit_sha: sha.to_string(), - title: title.to_string(), - message: String::new(), - change_id: change_id.to_string(), - slug: format!("{title}--00000000"), - note: String::new(), - } - } - - fn pull_merged(change_id: &str, head_sha: &str, number: u64) -> RemoteChange { - RemoteChange { - change_id: change_id.to_string(), - pull: json!({ - "number": number, - "state": "closed", - "merged_at": "2025-01-01T00:00:00Z", - "head": {"sha": head_sha, "ref": "stack/x"}, - }), - } - } - - fn pull_open(change_id: &str, head_sha: &str, number: u64) -> RemoteChange { - RemoteChange { - change_id: change_id.to_string(), - pull: json!({ - "number": number, - "state": "open", - "merged_at": null, - "head": {"sha": head_sha, "ref": "stack/x"}, - }), - } - } - - #[test] - fn create_when_no_matching_pull() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let res = classify(&[local("abc", cid, "A")], vec![]).unwrap(); - assert_eq!(res.locals[0].action, Action::Create); - assert!(res.locals[0].pull.is_none()); - } - - #[test] - fn skip_merged_when_head_matches() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let res = classify(&[local("abc", cid, "A")], vec![pull_merged(cid, "abc", 1)]).unwrap(); - assert_eq!(res.locals[0].action, Action::SkipMerged); - assert!(res.locals[0].pull.is_some()); - } - - #[test] - fn create_when_amended_after_merge() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - // PR head = abc, local = def (amended after merge). - let res = classify(&[local("def", cid, "A")], vec![pull_merged(cid, "abc", 1)]).unwrap(); - assert_eq!(res.locals[0].action, Action::Create); - // The merged pull is disassociated. - assert!(res.locals[0].pull.is_none()); - } - - #[test] - fn skip_up_to_date_when_open_pr_head_matches() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let res = classify(&[local("abc", cid, "A")], vec![pull_open(cid, "abc", 1)]).unwrap(); - assert_eq!(res.locals[0].action, Action::SkipUpToDate); - } - - #[test] - fn update_when_open_pr_head_differs() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let res = classify(&[local("def", cid, "A")], vec![pull_open(cid, "abc", 1)]).unwrap(); - assert_eq!(res.locals[0].action, Action::Update); - } - - #[test] - fn unmatched_open_pull_becomes_orphan() { - let res = classify( - &[], - vec![pull_open( - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01", - "abc", - 1, - )], - ) - .unwrap(); - assert_eq!(res.orphans.len(), 1); - } - - #[test] - fn closed_unmatched_pull_is_not_an_orphan() { - // Merged but unmatched — the user dropped the commit locally - // and the PR is already closed. Don't surface it. - let res = classify( - &[], - vec![pull_merged( - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01", - "abc", - 1, - )], - ) - .unwrap(); - assert!(res.orphans.is_empty()); - } - - fn change(action: Action, pull: Option) -> LocalChange { - LocalChange { - commit_sha: "abc1234def5678".to_string(), - title: "Add feature".to_string(), - message: String::new(), - change_id: "Iaaaa".to_string(), - action, - pull, - note: String::new(), - } - } - - #[test] - fn format_create_plan_uses_branch_placeholder_url() { - let c = change(Action::Create, None); - let line = format_local_change_log(&c, "stack/feat/x", true, false); - assert_eq!(line, "* [to create] 'abc1234 - Add feature "); - } - - #[test] - fn format_create_done_uses_pull_url_and_draft_flag() { - let pull = json!({"html_url": "https://gh/pull/7", "draft": true}); - let c = change(Action::Create, Some(pull)); - let line = format_local_change_log(&c, "stack/feat/x", false, false); - assert_eq!( - line, - "* [created] 'abc1234 - Add feature (draft) https://gh/pull/7" - ); - } - - #[test] - fn format_create_as_draft_flag_applies_without_pull() { - let c = change(Action::Create, None); - let line = format_local_change_log(&c, "stack/feat/x", true, true); - assert_eq!( - line, - "* [to create] 'abc1234 - Add feature (draft) " - ); - } - - #[test] - fn format_update_shows_old_to_new_head() { - let pull = json!({"html_url": "https://gh/pull/7", "head": {"sha": "fff0000aaa"}}); - let c = change(Action::Update, Some(pull)); - let line = format_local_change_log(&c, "stack/feat/x", true, false); - assert_eq!( - line, - "* [to update] 'fff0000 -> abc1234 - Add feature https://gh/pull/7" - ); - } - - #[test] - fn format_skip_up_to_date() { - let pull = json!({"html_url": "https://gh/pull/7", "head": {"sha": "abc1234def5678"}}); - let c = change(Action::SkipUpToDate, Some(pull)); - let line = format_local_change_log(&c, "stack/feat/x", false, false); - assert_eq!( - line, - "* [up-to-date] 'abc1234 - Add feature https://gh/pull/7" - ); - } - - #[test] - fn format_skip_merged_uses_merge_commit_and_flag() { - let pull = json!({ - "html_url": "https://gh/pull/7", - "merged_at": "2025-01-01T00:00:00Z", - "merge_commit_sha": "9998887ccc", - }); - let c = change(Action::SkipMerged, Some(pull)); - let line = format_local_change_log(&c, "stack/feat/x", false, false); - assert_eq!( - line, - "* [merged] '9998887 - Add feature (merged) https://gh/pull/7" - ); - } - - #[test] - fn format_orphan_plan_and_deleted() { - let pull = json!({ - "title": "Old PR", - "html_url": "https://gh/pull/9", - "head": {"sha": "deadbeef0000"}, - }); - assert_eq!( - format_orphan_change_log(&pull, true), - "* [to delete] 'deadbee - Old PR https://gh/pull/9" - ); - assert_eq!( - format_orphan_change_log(&pull, false), - "* [deleted] 'deadbee - Old PR https://gh/pull/9" - ); - } -} diff --git a/crates/mergify-stack/src/commands/checkout.rs b/crates/mergify-stack/src/commands/checkout.rs deleted file mode 100644 index 0e1f3c8e..00000000 --- a/crates/mergify-stack/src/commands/checkout.rs +++ /dev/null @@ -1,961 +0,0 @@ -//! `mergify stack checkout ` — fetch a stack of -//! pull requests from GitHub and create a local branch tracking -//! the leaf head. -//! -//! The target is whatever identifies the stack on GitHub: -//! -//! - a **stack branch**, exactly as it exists on the remote -//! (`stack/jd/my-feature`, `mystacks/foo`, `feature-x`, …). A -//! leaf ref pasted verbatim works too — the trailing Change-Id -//! segment gets stripped back to the stack stem. -//! - a **pull-request URL**, from any position in the stack. The -//! PR's `head.ref` yields the stack branch, so a URL grabbed -//! from the middle of a stack still checks out the whole thing. -//! -//! Nothing is derived from a user login. Other people's stacks, -//! and stacks under a branch prefix with no author in it, are as -//! reachable as your own. -//! -//! The flow: -//! -//! 1. Resolve the target into a stack branch (+ `owner/repo`, -//! which a PR URL carries). -//! 2. Look up the stack's PRs (via -//! [`crate::remote_changes::get_remote_changes`], with no -//! author filter — a branch name is unique within a repo). Only -//! open PRs make the chain, and an open PR's branch always -//! exists, so no local commits are needed to find them. -//! 3. Link open PRs into a single chain via their `head.ref` → -//! `base.ref` pointers, find the root (the PR whose `base.ref` -//! is *outside* the stack — i.e. doesn't start with the stack -//! branch prefix), walk up to the leaf. -//! 4. Print the chain. When not `--dry-run`, `git fetch` the leaf -//! head, `git checkout -b ` on it, set upstream -//! tracking to the root's base. - -use std::path::Path; - -use crate::git::run_git_silent as run_git; - -use mergify_core::CliError; -use mergify_core::HttpClient; -use serde_json::Value; -use url::Url; - -use crate::change_id; -use crate::remote_changes::{self, RemoteChange}; - -/// Pull-request summary surfaced to the caller for rendering. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PullSummary { - pub number: u64, - pub title: String, - pub html_url: String, - pub base_ref: String, - pub head_ref: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Stack discovered. `chain` is base→leaf order. `created` - /// indicates whether a local branch was actually checked out - /// (false for `--dry-run`). - CheckedOut { - chain: Vec, - created: bool, - local_branch: String, - upstream: String, - /// The remote stack branch the target resolved to. Worth - /// echoing back: when the target was a PR URL, the user - /// never typed it. - stack_branch: String, - /// Whether the stack sits under this machine's stack - /// branch prefix. When it doesn't, the other stack - /// commands — which still scope themselves to the local - /// user's own prefix and login — won't reach these pull - /// requests from the branch just created. - under_local_prefix: bool, - }, - NoStackedPrs, -} - -/// What the user pointed `stack checkout` at. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Target { - /// A stack branch, already stripped of any trailing Change-Id - /// segment. - Branch(String), - /// A pull request, identified by the `//pull/` - /// part of its URL. The host is deliberately ignored — requests - /// go to the configured API endpoint either way, and demanding - /// a match would only reject working input on the GHES setups - /// where the web host and the API host differ. - Pull { - owner: String, - repo: String, - number: u64, - }, -} - -/// Classify the raw CLI argument. -/// -/// An `http(s)://` target has to be a pull request URL — letting a -/// wrong one through as a "branch" would only produce a baffling -/// "no stacked PRs" further down. A schemeless target is read as a -/// pull request URL too when it carries a host-looking first -/// segment (`github.com/o/r/pull/12`, what a copied chat link -/// usually looks like); anything else is a branch name. -pub fn parse_target(raw: &str) -> Result { - let invalid = || { - CliError::InvalidState(format!( - "`{raw}` is not a pull request URL — expected `http(s)://///pull/`", - )) - }; - - if raw.starts_with("https://") || raw.starts_with("http://") { - let url = Url::parse(raw).map_err(|_| invalid())?; - let segments: Vec<&str> = url - .path_segments() - .map(Iterator::collect) - .unwrap_or_default(); - // A scheme is an unambiguous "this is a URL", so a shape - // that doesn't match is an error rather than a branch. - return match_pull(&segments).ok_or_else(invalid); - } - - // Trailing slashes come from shell completion on a remote ref; - // left in place they'd survive into an empty final segment. - let raw = raw.trim_end_matches('/'); - // `///pull/` — the leading dot-bearing - // segment is what separates a pasted link from a branch that - // merely contains a `pull` segment. - if let [host, rest @ ..] = raw.split('/').collect::>().as_slice() - && host.contains('.') - && let Some(target) = match_pull(rest) - { - return Ok(target); - } - Ok(Target::Branch(change_id::strip_branch_suffix(raw))) -} - -/// Match `//pull/` at the head of `segments`. -/// Trailing segments (`/files`, `/commits/`, …) and any -/// fragment are what a browser address bar hands you, so they are -/// matched past and ignored. -fn match_pull(segments: &[&str]) -> Option { - match segments { - [owner, repo, "pull", number, ..] if !owner.is_empty() && !repo.is_empty() => { - Some(Target::Pull { - owner: (*owner).to_string(), - repo: (*repo).to_string(), - number: number.parse().ok()?, - }) - } - _ => None, - } -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub client: &'a HttpClient, - /// `--repository` override, in `owner/repo` form. `None` reads - /// the remote's URL. Consulted only for a branch target — a PR - /// URL carries its own owner and repo, and resolving the local - /// remote for it could fail on a repository that has nothing to - /// do with the target. - pub repository: Option<&'a str>, - /// Raw target argument from the CLI — a stack branch or a pull - /// request URL. Classified by [`parse_target`]. - pub target: &'a str, - /// Local branch name override. `None` derives one from the - /// stack branch — see [`derive_local_branch`]. - pub local_branch: Option<&'a str>, - /// This machine's stack branch prefix, as the other stack - /// commands would compute it. Used only to name the local - /// branch, and to tell a stack of your own from someone - /// else's; `None` when the caller couldn't determine one. - pub local_stack_prefix: Option<&'a str>, - /// Remote name to fetch from — typically `origin`. Comes from - /// the trunk's first segment. - pub remote: &'a str, - pub dry_run: bool, -} - -pub async fn run(opts: &Options<'_>) -> Result { - let (owner, repo, stack_branch) = resolve_stack_branch(opts).await?; - - let below_prefix = opts - .local_stack_prefix - .and_then(|prefix| stack_branch.strip_prefix(&format!("{prefix}/"))); - let under_local_prefix = below_prefix.is_some(); - let local_branch = match opts.local_branch { - Some(branch) => branch.to_string(), - None => derive_local_branch(&stack_branch, below_prefix), - }; - - let remote_changes = - remote_changes::get_remote_changes(opts.client, &owner, &repo, &stack_branch, None, &[]) - .await?; - - let chain = build_chain(&remote_changes, &stack_branch)?; - if chain.is_empty() { - return Ok(Outcome::NoStackedPrs); - } - - let upstream = format!( - "{remote}/{base}", - remote = opts.remote, - base = chain[0].base_ref - ); - - if opts.dry_run { - return Ok(Outcome::CheckedOut { - chain, - created: false, - local_branch, - upstream, - stack_branch, - under_local_prefix, - }); - } - - let leaf_head = chain.last().expect("non-empty chain").head_ref.clone(); - let head_ref = format!("{remote}/{leaf_head}", remote = opts.remote); - run_git(opts.repo_dir, &["fetch", opts.remote, &leaf_head])?; - run_git(opts.repo_dir, &["checkout", "-b", &local_branch, &head_ref])?; - run_git( - opts.repo_dir, - &["branch", &format!("--set-upstream-to={upstream}")], - )?; - Ok(Outcome::CheckedOut { - chain, - created: true, - local_branch, - upstream, - stack_branch, - under_local_prefix, - }) -} - -/// Name the local branch for a stack branch the user didn't name -/// one for. -/// -/// `below_prefix` is what's left of the stack branch after this -/// machine's stack prefix — `Some` only when the stack sits under -/// it. That remainder is the branch the stack was pushed from, and -/// restoring it exactly is what lets a later `stack push` find the -/// same stack instead of opening a duplicate set of pull requests: -/// push recomposes `/`, so a multi-segment name -/// like `feature/login` has to survive whole. -/// -/// Without that (someone else's stack, or no prefix to compare -/// against) there's no way to tell where the prefix ends, and the -/// last segment is the closest thing to the name a human chose. -fn derive_local_branch(stack_branch: &str, below_prefix: Option<&str>) -> String { - below_prefix.map_or_else( - || { - stack_branch - .rsplit('/') - .next() - .unwrap_or(stack_branch) - .to_string() - }, - ToString::to_string, - ) -} - -/// Turn the raw target into `(owner, repo, stack_branch)`. A PR -/// URL costs one extra request and names its own repository; a -/// branch target resolves the local one, which is why that lookup -/// happens here and not before the target is classified. -async fn resolve_stack_branch(opts: &Options<'_>) -> Result<(String, String, String), CliError> { - let (owner, repo, stack_branch) = match parse_target(opts.target)? { - Target::Branch(branch) => { - let slug = - crate::stack_context::resolve_repo(opts.repo_dir, opts.repository, opts.remote)?; - (slug.owner, slug.repo, branch) - } - Target::Pull { - owner, - repo, - number, - } => { - let pull: Value = opts - .client - .get(&format!("/repos/{owner}/{repo}/pulls/{number}")) - .await?; - let head_ref = pull_field(&pull, "head", "ref")?; - let stack_branch = change_id::strip_branch_suffix(&head_ref); - (owner, repo, stack_branch) - } - }; - if stack_branch.is_empty() { - return Err(CliError::InvalidState( - "empty stack branch — pass the stack's branch name or a pull request URL".to_string(), - )); - } - Ok((owner, repo, stack_branch)) -} - -/// Walk the remote-changes graph and return the open-PR chain -/// from root → leaf. Open PRs are linked via `head.ref` → -/// `base.ref`; the root is the one whose `base.ref` doesn't start -/// with the stack branch prefix. Two-root layouts are surfaced as -/// `InvalidState`, matching Python. -fn build_chain( - remote_changes: &[RemoteChange], - stack_branch: &str, -) -> Result, CliError> { - // Build a base.ref → pull map of open PRs. - let mut nodes: std::collections::HashMap = std::collections::HashMap::new(); - for change in remote_changes { - let state = change.pull.get("state").and_then(Value::as_str); - if state != Some("open") { - continue; - } - let base_ref = pull_field(&change.pull, "base", "ref")?; - nodes.insert(base_ref, &change.pull); - } - if nodes.is_empty() { - return Ok(Vec::new()); - } - - // Find the root — the PR whose base.ref doesn't start with - // the stack branch (i.e. it's the trunk side). - let mut root: Option<&Value> = None; - for pull in nodes.values() { - let base_ref = pull_field(pull, "base", "ref")?; - if !base_ref.starts_with(stack_branch) { - if root.is_some() { - return Err(CliError::InvalidState( - "unexpected stack layout, two root commits found".to_string(), - )); - } - root = Some(*pull); - } - } - let Some(mut current) = root else { - return Ok(Vec::new()); - }; - - // Walk from root to leaf following head.ref → base.ref links. - let mut chain: Vec = Vec::new(); - loop { - chain.push(summary_from(current)?); - let head_ref = pull_field(current, "head", "ref")?; - match nodes.get(&head_ref) { - Some(next) => current = next, - None => break, - } - } - Ok(chain) -} - -fn pull_field(pull: &Value, parent: &str, child: &str) -> Result { - pull.get(parent) - .and_then(|p| p.get(child)) - .and_then(Value::as_str) - .map(str::to_owned) - .ok_or_else(|| { - CliError::Generic(format!("pull request payload missing `{parent}.{child}`")) - }) -} - -fn summary_from(pull: &Value) -> Result { - let number = pull - .get("number") - .and_then(Value::as_u64) - .ok_or_else(|| CliError::Generic("pull missing `number`".to_string()))?; - let title = pull - .get("title") - .and_then(Value::as_str) - .map(str::to_owned) - .ok_or_else(|| CliError::Generic("pull missing `title`".to_string()))?; - let html_url = pull - .get("html_url") - .and_then(Value::as_str) - .map(str::to_owned) - .ok_or_else(|| CliError::Generic("pull missing `html_url`".to_string()))?; - Ok(PullSummary { - number, - title, - html_url, - base_ref: pull_field(pull, "base", "ref")?, - head_ref: pull_field(pull, "head", "ref")?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn pull(number: u64, base: &str, head: &str) -> RemoteChange { - RemoteChange { - change_id: format!("I{number:040}"), - pull: json!({ - "number": number, - "title": format!("PR #{number}"), - "html_url": format!("https://github.com/o/r/pull/{number}"), - "state": "open", - "base": {"ref": base}, - "head": {"ref": head}, - }), - } - } - - #[test] - fn builds_chain_from_root_to_leaf() { - // Stack: main → stack/a/1 → stack/a/2 → stack/a/3 - let stack = "stack/a"; - let changes = vec![ - pull(1, "main", "stack/a/1"), - pull(2, "stack/a/1", "stack/a/2"), - pull(3, "stack/a/2", "stack/a/3"), - ]; - let chain = build_chain(&changes, stack).unwrap(); - let nums: Vec = chain.iter().map(|p| p.number).collect(); - assert_eq!(nums, [1, 2, 3]); - } - - #[test] - fn skips_closed_prs() { - let stack = "stack/a"; - let changes = vec![pull(1, "main", "stack/a/1"), { - let mut c = pull(2, "stack/a/1", "stack/a/2"); - c.pull["state"] = json!("closed"); - c - }]; - let chain = build_chain(&changes, stack).unwrap(); - // Closed PR is skipped — chain is just the root (no leaf). - let nums: Vec = chain.iter().map(|p| p.number).collect(); - assert_eq!(nums, [1]); - } - - #[test] - fn detects_two_roots() { - let stack = "stack/a"; - // Both PRs have base.ref outside the stack prefix — two - // candidate roots, which is malformed. - let changes = vec![ - pull(1, "main", "stack/a/1"), - pull(2, "develop", "stack/a/2"), - ]; - let err = build_chain(&changes, stack).unwrap_err(); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("two root commits"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn no_open_prs_returns_empty() { - let stack = "stack/a"; - let mut c = pull(1, "main", "stack/a/1"); - c.pull["state"] = json!("closed"); - let chain = build_chain(&[c], stack).unwrap(); - assert!(chain.is_empty()); - } - - #[tokio::test] - async fn run_no_stacked_prs_returns_no_stacked_prs() { - use mergify_core::ApiFlavor; - use url::Url; - use wiremock::MockServer; - - let server = MockServer::start().await; - mount_discovery(&server, "stack/author/my-branch", &[]).await; - - let client = HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap(); - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("user/repo"), - local_stack_prefix: None, - target: "stack/author/my-branch", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - assert!(matches!(outcome, Outcome::NoStackedPrs)); - } - - #[tokio::test] - async fn run_dry_run_returns_chain_without_touching_git() { - use mergify_core::ApiFlavor; - use url::Url; - use wiremock::MockServer; - - let server = MockServer::start().await; - // Head refs use the new-format `--` shape so - // `extract_from_branch_segment` accepts them and the - // remote_changes pipeline doesn't filter them out. - mount_discovery( - &server, - "stack/author/my-branch", - &[ - serde_json::json!({ - "number": 1, - "title": "feat: A", - "html_url": "https://github.com/user/repo/pull/1", - "state": "open", - "base": {"ref": "main"}, - "head": {"ref": "stack/author/my-branch/feat-a--aaaaaaaa"}, - "merged_at": null, - }), - serde_json::json!({ - "number": 2, - "title": "feat: B", - "html_url": "https://github.com/user/repo/pull/2", - "state": "open", - "base": {"ref": "stack/author/my-branch/feat-a--aaaaaaaa"}, - "head": {"ref": "stack/author/my-branch/feat-b--bbbbbbbb"}, - "merged_at": null, - }), - ], - ) - .await; - - let client = HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap(); - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("user/repo"), - local_stack_prefix: None, - target: "stack/author/my-branch", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - match outcome { - Outcome::CheckedOut { - chain, - created, - local_branch, - upstream, - .. - } => { - let nums: Vec = chain.iter().map(|p| p.number).collect(); - assert_eq!(nums, [1, 2]); - assert!(!created); - assert_eq!(local_branch, "my-branch"); - assert_eq!(upstream, "origin/main"); - } - Outcome::NoStackedPrs => panic!("unexpected NoStackedPrs"), - } - } - - #[tokio::test] - async fn run_strips_changeid_suffix_from_a_pasted_leaf_ref() { - // The user pastes a leaf branch ref verbatim, current - // `--<8 hex>` naming included. The branches have to be - // listed under the stack stem, not the leaf. - use mergify_core::ApiFlavor; - use url::Url; - use wiremock::MockServer; - - let server = MockServer::start().await; - mount_discovery(&server, "stack/author/my-branch", &[]).await; - - let client = HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap(); - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("user/repo"), - local_stack_prefix: None, - target: "stack/author/my-branch/feat-b--bbbbbbbb", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - assert!(matches!(outcome, Outcome::NoStackedPrs)); - } - - #[test] - fn parse_target_reads_owner_repo_and_number_from_a_pull_url() { - assert_eq!( - parse_target("https://github.com/Mergifyio/mergify-cli/pull/1774").unwrap(), - Target::Pull { - owner: "Mergifyio".to_string(), - repo: "mergify-cli".to_string(), - number: 1774, - }, - ); - } - - #[test] - fn parse_target_accepts_a_pull_url_with_tab_suffix_and_fragment() { - // What you get from a browser address bar mid-review. - assert_eq!( - parse_target("https://github.com/o/r/pull/12/files#diff-abc").unwrap(), - Target::Pull { - owner: "o".to_string(), - repo: "r".to_string(), - number: 12, - }, - ); - } - - #[test] - fn parse_target_accepts_a_pull_url_on_an_enterprise_host() { - assert_eq!( - parse_target("https://ghe.example.com/o/r/pull/7").unwrap(), - Target::Pull { - owner: "o".to_string(), - repo: "r".to_string(), - number: 7, - }, - ); - } - - #[test] - fn parse_target_treats_anything_else_as_a_branch_and_strips_the_changeid() { - assert_eq!( - parse_target("stack/author/my-branch/feat-b--bbbbbbbb").unwrap(), - Target::Branch("stack/author/my-branch".to_string()), - ); - assert_eq!( - parse_target("my-feature").unwrap(), - Target::Branch("my-feature".to_string()), - ); - } - - #[test] - fn parse_target_accepts_a_pull_url_pasted_without_its_scheme() { - // Copying a PR link out of a chat message routinely loses - // the scheme. Treating it as a branch would search for a - // stack that cannot exist and report "no stacked PRs". - assert_eq!( - parse_target("github.com/Mergifyio/mergify-cli/pull/1774").unwrap(), - Target::Pull { - owner: "Mergifyio".to_string(), - repo: "mergify-cli".to_string(), - number: 1774, - }, - ); - } - - #[test] - fn parse_target_keeps_a_branch_whose_segments_merely_resemble_a_pull_url() { - // No host-looking first segment, so this is a branch that - // happens to contain `pull`, not a URL. - assert_eq!( - parse_target("team/repo/pull/12").unwrap(), - Target::Branch("team/repo/pull/12".to_string()), - ); - } - - #[test] - fn parse_target_trims_trailing_slashes_from_a_branch() { - // Shell tab-completion on a remote ref adds the slash. It - // would otherwise survive into an empty last segment and - // make `git checkout -b ''` fail with a git-level error. - assert_eq!( - parse_target("stack/author/my-branch/").unwrap(), - Target::Branch("stack/author/my-branch".to_string()), - ); - } - - #[test] - fn parse_target_rejects_a_url_that_is_not_a_pull_request() { - // Treating this as a branch name would send the user into - // a confusing "no stacked PRs" instead of naming the - // mistake. - let err = parse_target("https://github.com/o/r/issues/12").unwrap_err(); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("pull request URL"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - } - - #[tokio::test] - async fn run_checks_out_the_whole_stack_from_a_middle_pull_url() { - // A PR URL from the middle of a three-PR stack must yield - // the full root→leaf chain, not just that PR and below. - use mergify_core::ApiFlavor; - use url::Url; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn pr_body(number: u64, base: &str, head: &str) -> serde_json::Value { - serde_json::json!({ - "number": number, - "title": format!("feat: {number}"), - "html_url": format!("https://github.com/user/repo/pull/{number}"), - "state": "open", - "base": {"ref": base}, - "head": {"ref": head}, - "merged_at": null, - "user": {"login": "someone-else"}, - }) - } - - let stack = "stack/author/my-branch"; - let heads = [ - format!("{stack}/feat-a--aaaaaaaa"), - format!("{stack}/feat-b--bbbbbbbb"), - format!("{stack}/feat-c--cccccccc"), - ]; - let bodies = [ - pr_body(1, "main", &heads[0]), - pr_body(2, &heads[0], &heads[1]), - pr_body(3, &heads[1], &heads[2]), - ]; - - let server = MockServer::start().await; - // No author filter — checkout reaches anyone's stack, so PRs - // opened by someone else still make the chain. - mount_discovery(&server, stack, &bodies).await; - // The PR URL is resolved to its stack branch first. - for (i, body) in bodies.iter().enumerate() { - Mock::given(method("GET")) - .and(path(format!("/repos/user/repo/pulls/{}", i + 1))) - .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) - .mount(&server) - .await; - } - - let client = HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap(); - - let outcome = run(&Options { - repo_dir: None, - client: &client, - // Deliberately wrong — the PR URL's owner/repo wins. - repository: Some("somebody-else/other-repo"), - local_stack_prefix: None, - target: "https://github.com/user/repo/pull/2", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - - match outcome { - Outcome::CheckedOut { - chain, - local_branch, - upstream, - stack_branch, - .. - } => { - let nums: Vec = chain.iter().map(|p| p.number).collect(); - assert_eq!(nums, [1, 2, 3]); - assert_eq!(stack_branch, stack); - // Local branch defaults to the stack's last segment. - assert_eq!(local_branch, "my-branch"); - assert_eq!(upstream, "origin/main"); - } - Outcome::NoStackedPrs => panic!("unexpected NoStackedPrs"), - } - } - - /// Mount the stack discovery for `stack_branch`: each of `pulls` - /// listed as a live branch under it, and returned by the lookup - /// on its own `head.ref`. The branches must be listed exactly - /// once, under the stack stem. - async fn mount_discovery( - server: &wiremock::MockServer, - stack_branch: &str, - pulls: &[serde_json::Value], - ) { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, ResponseTemplate}; - - let head_ref = |pull: &serde_json::Value| pull["head"]["ref"].as_str().unwrap().to_string(); - let refs: Vec<_> = pulls - .iter() - .map(|pull| serde_json::json!({"ref": format!("refs/heads/{}", head_ref(pull))})) - .collect(); - Mock::given(method("GET")) - .and(path(format!( - "/repos/user/repo/git/matching-refs/heads/{stack_branch}/" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(refs)) - .expect(1) - .mount(server) - .await; - for pull in pulls { - Mock::given(method("GET")) - .and(path("/repos/user/repo/pulls")) - .and(query_param("head", format!("user:{}", head_ref(pull)))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([pull]))) - .expect(1) - .mount(server) - .await; - } - } - - /// Mount a two-PR stack under `stack_branch` and return the - /// client pointed at it. The server has to outlive the call, - /// so it comes back with the client. - async fn stack_server(stack_branch: &str) -> (wiremock::MockServer, HttpClient) { - use mergify_core::ApiFlavor; - use url::Url; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let heads = [ - format!("{stack_branch}/feat-a--aaaaaaaa"), - format!("{stack_branch}/feat-b--bbbbbbbb"), - ]; - let bodies = [ - serde_json::json!({ - "number": 1, "title": "feat: A", "state": "open", - "html_url": "https://github.com/user/repo/pull/1", - "base": {"ref": "main"}, "head": {"ref": heads[0]}, "merged_at": null, - }), - serde_json::json!({ - "number": 2, "title": "feat: B", "state": "open", - "html_url": "https://github.com/user/repo/pull/2", - "base": {"ref": heads[0]}, "head": {"ref": heads[1]}, "merged_at": null, - }), - ]; - - let server = MockServer::start().await; - mount_discovery(&server, stack_branch, &bodies).await; - // A pull-request URL target is resolved to its stack branch - // through the PR itself. - for (i, body) in bodies.iter().enumerate() { - Mock::given(method("GET")) - .and(path(format!("/repos/user/repo/pulls/{}", i + 1))) - .respond_with(ResponseTemplate::new(200).set_body_json(body.clone())) - .mount(&server) - .await; - } - - let client = HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap(); - (server, client) - } - - #[tokio::test] - async fn run_keeps_the_whole_branch_name_below_the_local_stack_prefix() { - // `stack new feature/login` pushes to - // `/feature/login/…`. Checking that back out has to - // restore `feature/login` — a local branch of just `login` - // would make the next `stack push` compute - // `/login`, match nothing, and open a duplicate set - // of pull requests for the same commits. - let (_server, client) = stack_server("devs/jd/feature/login").await; - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("user/repo"), - local_stack_prefix: Some("devs/jd"), - target: "devs/jd/feature/login", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - - match outcome { - Outcome::CheckedOut { - local_branch, - under_local_prefix, - .. - } => { - assert_eq!(local_branch, "feature/login"); - assert!(under_local_prefix); - } - Outcome::NoStackedPrs => panic!("unexpected NoStackedPrs"), - } - } - - #[tokio::test] - async fn run_flags_a_stack_that_is_not_under_the_local_prefix() { - // Someone else's stack: there's no prefix to strip, so the - // last segment is the best local name — and `stack push` - // from it would not reach their pull requests, which the - // caller needs to be able to say. - let (_server, client) = stack_server("devs/alice/feat").await; - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("user/repo"), - local_stack_prefix: Some("devs/jd"), - target: "devs/alice/feat", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - - match outcome { - Outcome::CheckedOut { - local_branch, - under_local_prefix, - .. - } => { - assert_eq!(local_branch, "feat"); - assert!(!under_local_prefix); - } - Outcome::NoStackedPrs => panic!("unexpected NoStackedPrs"), - } - } - - #[tokio::test] - async fn run_does_not_resolve_the_repository_for_a_pull_url_target() { - // A PR URL carries its own owner/repo, so an unparseable - // `--repository` must not be consulted at all. - let (_server, client) = stack_server("stack/author/my-branch").await; - - let outcome = run(&Options { - repo_dir: None, - client: &client, - repository: Some("not-a-slug"), - local_stack_prefix: None, - target: "https://github.com/user/repo/pull/2", - local_branch: None, - remote: "origin", - dry_run: true, - }) - .await - .unwrap(); - - match outcome { - Outcome::CheckedOut { stack_branch, .. } => { - assert_eq!(stack_branch, "stack/author/my-branch"); - } - Outcome::NoStackedPrs => panic!("unexpected NoStackedPrs"), - } - } -} diff --git a/crates/mergify-stack/src/commands/drop.rs b/crates/mergify-stack/src/commands/drop.rs deleted file mode 100644 index 1c86064e..00000000 --- a/crates/mergify-stack/src/commands/drop.rs +++ /dev/null @@ -1,351 +0,0 @@ -//! `mergify stack drop ... [--dry-run]` — remove one or -//! more commits from the stack. -//! -//! Port of `mergify_cli/stack/drop.py::stack_drop`. Uses the -//! shared rebase-todo machinery added with `stack edit`: spawn -//! `git rebase -i ` with `GIT_SEQUENCE_EDITOR` pointing at -//! `mergify _internal rebase-todo-rewrite --action drop --shas -//! `, which deletes the targeted `pick` lines from the -//! todo before git replays the rebase. - -use std::path::Path; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -/// One commit the caller wants to drop, used for prefix -/// resolution before building the full-stack plan. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DroppedCommit { - pub sha: String, - pub subject: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Drop ran to completion. `plan` is the full stack in - /// `base..HEAD` order with `[drop]` tags on the dropped rows. - Dropped { plan: Vec }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { plan: Vec }, - /// Stack is empty — Python prints `No commits in the stack` - /// and exits 0. - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefixes: &'a [String], - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -/// Resolve the trunk, walk the stack, match each `` -/// argument against the walker, then spawn the scripted rebase. -/// -/// Errors: -/// - [`CliError::StackNotFound`] for an unresolved trunk or a -/// commit prefix that doesn't match any stack commit. -/// - [`CliError::InvalidState`] for a duplicate prefix (two -/// `` args resolving to the same commit) or an -/// ambiguous Change-Id prefix that matches more than one. -/// - [`CliError::Generic`] for git invocation failures. -pub fn run(opts: &Options<'_>) -> Result { - if opts.commit_prefixes.is_empty() { - return Err(CliError::InvalidState("no commits to drop".to_string())); - } - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let mut resolved = Vec::with_capacity(opts.commit_prefixes.len()); - let mut seen_shas = std::collections::HashSet::new(); - for prefix in opts.commit_prefixes { - let matched = match_commit(prefix, &commits)?; - if !seen_shas.insert(matched.sha.clone()) { - return Err(CliError::InvalidState(format!( - "duplicate — prefix '{prefix}' resolves to the same commit as another prefix" - ))); - } - resolved.push(matched); - } - - let plan: Vec = commits - .iter() - .map(|c| PlanRow { - sha: c.commit_sha.clone(), - subject: c.title.clone(), - change_id: c.change_id.clone(), - action: seen_shas - .contains(&c.commit_sha) - .then(|| "drop".to_string()), - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - let shas: Vec = resolved.iter().map(|c| c.sha.clone()).collect(); - let editor = build_sequence_editor(opts.mergify_binary, &shas); - spawn_rebase(&repo_dir, &base, Some(&editor), false)?; - Ok(Outcome::Dropped { plan }) -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(DroppedCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - }), - many => { - let candidates: Vec> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn build_sequence_editor(binary: &Path, shas: &[String]) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let shas = shell_quote(&shas.join(",")); - format!("{bin} _internal rebase-todo-rewrite --action drop --shas {shas}") -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - /// Bare upstream + local clone with three commits on `feature` - /// (each carrying a Change-Id trailer). - fn build_stack_repo() -> (TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid.to_string())); - } - (workdir, commits) - } - - fn run_in(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {dir:?} {args:?} failed"); - } - - fn capture(dir: &Path, args: &[&str]) -> String { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[test] - fn dry_run_resolves_commits_without_spawning_rebase() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[commits[1].0[..12].to_string()], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - match outcome { - Outcome::DryRun { plan } => { - // Plan lists the whole stack (A, B, C); only B is - // tagged for drop. - assert_eq!(plan.len(), 3); - assert_eq!(plan[1].sha, commits[1].0); - assert_eq!(plan[1].subject, "Commit B"); - assert_eq!(plan[1].action.as_deref(), Some("drop")); - assert_eq!(plan[0].action, None); - assert_eq!(plan[2].action, None); - } - other => panic!("unexpected: {other:?}"), - } - // HEAD untouched. - assert_eq!(capture(&local, &["log", "-1", "--format=%s"]), "Commit C"); - } - - #[test] - fn duplicate_prefix_is_rejected() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let err = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[ - commits[1].0[..7].to_string(), - // Same commit, different prefix length. - commits[1].0[..12].to_string(), - ], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("duplicate"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn change_id_prefix_resolves_against_stack() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[commits[2].1[..9].to_string()], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - match outcome { - Outcome::DryRun { plan } => { - // C (index 2 in the stack) is the change-id match - // and the only tagged row. - assert_eq!(plan[2].sha, commits[2].0); - assert_eq!(plan[2].action.as_deref(), Some("drop")); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn empty_stack_returns_empty_outcome() { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &["anything".to_string()], - dry_run: false, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - assert!(matches!(outcome, Outcome::EmptyStack)); - } - - #[test] - fn unknown_prefix_returns_stack_not_found() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - let err = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &["deadbeef1234".to_string()], - dry_run: false, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap_err(); - match err { - CliError::StackNotFound(_) => {} - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/commands/edit.rs b/crates/mergify-stack/src/commands/edit.rs deleted file mode 100644 index 5e15e571..00000000 --- a/crates/mergify-stack/src/commands/edit.rs +++ /dev/null @@ -1,279 +0,0 @@ -//! `mergify stack edit []` — pause an interactive rebase -//! at a specific commit (or open the rebase interactively when -//! `` is omitted) so the user can `git commit --amend` -//! the target without leaving an editor running for the whole -//! todo list. -//! -//! Port of `mergify_cli/stack/edit.py::stack_edit`. The -//! non-interactive path uses [`crate::rebase_todo`] via the -//! binary's `_internal rebase-todo-rewrite` subcommand, set as -//! `GIT_SEQUENCE_EDITOR` before spawning `git rebase -i `. - -use std::path::Path; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits; -use crate::trunk; - -/// One commit in the stack — what `match_commit` picks out of -/// the local walker result. Returned by [`run`] for the -/// non-interactive path so callers can render -/// `Editing commit: `. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EditingCommit { - pub sha: String, - pub subject: String, -} - -/// Result of [`run`]. The two variants mirror the Python flow: -/// no commit prefix → interactive rebase (we just wait for the -/// editor); with prefix → scripted rebase pauses at the target. -#[derive(Debug, Clone)] -pub enum Outcome { - /// `mergify stack edit` (no argument) — interactive rebase - /// returned. Nothing more for the caller to print. - InteractiveCompleted, - /// `mergify stack edit ` — the rebase is paused at - /// `commit`; the caller prints the editing notice and an - /// "amend then continue" hint. - PausedAt { commit: EditingCommit }, - /// Stack is empty (`..HEAD` returns - /// no commits). Python prints `No commits in the stack` and - /// returns 0. - EmptyStack, -} - -/// Parameters surfaced to the binary handler. `mergify_binary` -/// must point at the running binary so the spawned `git rebase` -/// can call back into `_internal rebase-todo-rewrite`. -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefix: Option<&'a str>, - pub mergify_binary: &'a Path, -} - -/// Resolve the trunk, compute the merge-base, then either spawn -/// `git rebase -i ` directly (interactive) or with a -/// `GIT_SEQUENCE_EDITOR` that marks the target commit as `edit`. -/// -/// Errors: -/// - [`CliError::StackNotFound`] for an unresolved trunk or a -/// commit prefix that doesn't match any stack commit. -/// - [`CliError::InvalidState`] for an ambiguous Change-Id -/// prefix that matches more than one commit (consistent with -/// `mergify stack note`'s behavior). -/// - [`CliError::Generic`] for git invocation failures. -pub fn run(opts: &Options<'_>) -> Result { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - - let Some(commit_prefix) = opts.commit_prefix else { - spawn_rebase(&repo_dir, &base, None, false)?; - return Ok(Outcome::InteractiveCompleted); - }; - - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let target = match_commit(commit_prefix, &commits)?; - // Print the editing notice *before* spawning the rebase so it - // precedes git's interactive-rebase output, matching Python's - // ordering (`console.print` then `_run_edit_rebase`). The - // "Amend the commit…" hint is printed by the caller after the - // rebase pauses. - let short = &target.sha[..target.sha.len().min(12)]; - println!( - "Editing commit: {short} {subject}", - subject = target.subject - ); - let editor = build_sequence_editor(opts.mergify_binary, &target.sha); - spawn_rebase(&repo_dir, &base, Some(&editor), false)?; - - Ok(Outcome::PausedAt { commit: target }) -} - -fn match_commit( - prefix: &str, - commits: &[local_commits::LocalCommit], -) -> Result { - let (matches, field): (Vec<&local_commits::LocalCommit>, &str) = if change_id::is_prefix(prefix) - { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(EditingCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - }), - many => { - let candidates: Vec> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn build_sequence_editor(binary: &Path, sha: &str) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let sha = shell_quote(sha); - format!("{bin} _internal rebase-todo-rewrite --action edit --sha {sha}") -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn build_stack_repo() -> (TempDir, Vec<(String, String)>) { - // Bare upstream + local clone, three commits each carrying - // a Change-Id trailer (the local_commits walker requires - // it). Returns (workdir, [(sha, change_id), ...]). - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let mut commits = Vec::new(); - for (label, cid_hex) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid_hex}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid_hex.to_string())); - } - (workdir, commits) - } - - fn run_in(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {dir:?} {args:?} failed"); - } - - fn capture(dir: &Path, args: &[&str]) -> String { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[test] - fn match_commit_resolves_by_sha_prefix() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let walker = local_commits::read( - &local, - &capture(&local, &["merge-base", "origin/main", "HEAD"]), - "HEAD", - ) - .unwrap(); - let target = match_commit(&commits[1].0[..7], &walker).unwrap(); - assert_eq!(target.sha, commits[1].0); - assert_eq!(target.subject, "Commit B"); - } - - #[test] - fn match_commit_resolves_by_change_id_prefix() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let walker = local_commits::read( - &local, - &capture(&local, &["merge-base", "origin/main", "HEAD"]), - "HEAD", - ) - .unwrap(); - let target = match_commit(&commits[1].1[..9], &walker).unwrap(); - assert_eq!(target.sha, commits[1].0); - } - - #[test] - fn match_commit_unknown_prefix_returns_stack_not_found() { - let (work, _) = build_stack_repo(); - let local = work.path().join("local"); - let walker = local_commits::read( - &local, - &capture(&local, &["merge-base", "origin/main", "HEAD"]), - "HEAD", - ) - .unwrap(); - let err = match_commit("deadbeef1234", &walker).unwrap_err(); - match err { - CliError::StackNotFound(msg) => { - assert!(msg.contains("deadbeef1234"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - // Note: end-to-end tests that actually spawn `git rebase -i` - // with `GIT_SEQUENCE_EDITOR` pointing at the real `mergify` - // binary live under `crates/mergify-cli/tests/stack_edit.rs`, - // where `CARGO_BIN_EXE_mergify` is set by cargo. The tests - // here cover the pure pieces that don't need the binary on - // disk (commit matching, repo-toplevel resolution). -} diff --git a/crates/mergify-stack/src/commands/fixup.rs b/crates/mergify-stack/src/commands/fixup.rs deleted file mode 100644 index bd11bd83..00000000 --- a/crates/mergify-stack/src/commands/fixup.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! `mergify stack fixup ... [--dry-run]` — fold one or -//! more commits into their parents in the stack. -//! -//! Port of `mergify_cli/stack/squash.py::stack_fixup`. Shares the -//! rebase-todo machinery with `stack drop` and `stack edit`: same -//! orchestrator shape, the only difference vs. drop is that this -//! command rewrites the targeted `pick` lines as `fixup` (preserving -//! the change so it folds into the previous commit) rather than -//! removing them outright. -//! -//! One extra validation specific to fixup: the first commit of the -//! stack has no parent inside the stack, so fixing it up would -//! collapse it into the trunk's parent. Python rejects this with -//! `INVALID_STATE`; we mirror the check. - -use std::path::Path; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FixedUpCommit { - pub sha: String, - pub subject: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Fixup ran to completion. `plan` is the full stack in - /// `base..HEAD` order with `[fixup]` tags on the folded rows. - Squashed { - plan: Vec, - }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { - plan: Vec, - }, - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefixes: &'a [String], - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -/// Resolve the trunk, walk the stack, match each `` -/// argument, ensure none of them is the first stack commit, then -/// spawn the scripted rebase. -/// -/// Errors: -/// - [`CliError::StackNotFound`] for an unresolved trunk or a -/// commit prefix that doesn't match any stack commit. -/// - [`CliError::InvalidState`] for a duplicate prefix (two -/// `` args resolving to the same commit), an ambiguous -/// Change-Id prefix, or targeting the first commit of the stack. -/// - [`CliError::Generic`] for git invocation failures. -pub fn run(opts: &Options<'_>) -> Result { - if opts.commit_prefixes.is_empty() { - return Err(CliError::InvalidState("no commits to fixup".to_string())); - } - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let first_sha = &commits[0].commit_sha; - - let mut resolved = Vec::with_capacity(opts.commit_prefixes.len()); - let mut seen_shas = std::collections::HashSet::new(); - for prefix in opts.commit_prefixes { - let matched = match_commit(prefix, &commits)?; - if &matched.sha == first_sha { - return Err(CliError::InvalidState( - "cannot fixup the first commit of the stack — no parent in stack".to_string(), - )); - } - if !seen_shas.insert(matched.sha.clone()) { - return Err(CliError::InvalidState(format!( - "duplicate — prefix '{prefix}' resolves to the same commit as another prefix" - ))); - } - resolved.push(matched); - } - - let plan: Vec = commits - .iter() - .map(|c| PlanRow { - sha: c.commit_sha.clone(), - subject: c.title.clone(), - change_id: c.change_id.clone(), - action: seen_shas - .contains(&c.commit_sha) - .then(|| "fixup".to_string()), - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - let shas: Vec = resolved.iter().map(|c| c.sha.clone()).collect(); - let editor = build_sequence_editor(opts.mergify_binary, &shas); - spawn_rebase(&repo_dir, &base, Some(&editor), false)?; - Ok(Outcome::Squashed { plan }) -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(FixedUpCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - }), - many => { - let candidates: Vec> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn build_sequence_editor(binary: &Path, shas: &[String]) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let shas = shell_quote(&shas.join(",")); - format!("{bin} _internal rebase-todo-rewrite --action fixup --shas {shas}") -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn build_stack_repo() -> (TempDir, Vec<(String, String)>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ("C", "Icccccccccccccccccccccccccccccccccccccc03"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let sha = capture(&local, &["rev-parse", "HEAD"]); - commits.push((sha, cid.to_string())); - } - (workdir, commits) - } - - fn run_in(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok); - } - - fn capture(dir: &Path, args: &[&str]) -> String { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[test] - fn first_commit_is_rejected() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let err = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[commits[0].0[..12].to_string()], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("first commit"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn dry_run_resolves_without_spawning_rebase() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[commits[1].0[..12].to_string()], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - match outcome { - Outcome::DryRun { plan } => { - // Plan lists the whole stack (A, B, C); only B is - // tagged for fixup. - assert_eq!(plan.len(), 3); - assert_eq!(plan[1].sha, commits[1].0); - assert_eq!(plan[1].subject, "Commit B"); - assert_eq!(plan[1].action.as_deref(), Some("fixup")); - assert_eq!(plan[0].action, None); - assert_eq!(plan[2].action, None); - } - other => panic!("unexpected: {other:?}"), - } - assert_eq!(capture(&local, &["log", "-1", "--format=%s"]), "Commit C"); - } - - #[test] - fn duplicate_prefix_is_rejected() { - let (work, commits) = build_stack_repo(); - let local = work.path().join("local"); - let err = run(&Options { - repo_dir: Some(&local), - commit_prefixes: &[ - commits[1].0[..7].to_string(), - commits[1].0[..12].to_string(), - ], - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("duplicate")); - } - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/commands/list.rs b/crates/mergify-stack/src/commands/list.rs deleted file mode 100644 index 4cfd28ad..00000000 --- a/crates/mergify-stack/src/commands/list.rs +++ /dev/null @@ -1,380 +0,0 @@ -//! `mergify stack list [--json] [--verbose]` — show the current -//! stack's commits and their associated PRs, optionally with CI -//! and review status. -//! -//! Port of `mergify_cli/stack/list.py::stack_list`. Uses the -//! shared [`crate::changes::classify`] for the per-commit -//! create/update/skip-merged/skip-up-to-date classification, -//! then optionally fans out per-PR `check-runs` and `reviews` -//! fetches the same way Python does. - -use std::path::Path; - -use crate::git::{resolve_repo_toplevel, run_git_capture}; - -use mergify_core::CliError; -use mergify_core::HttpClient; -use serde::Serialize; -use serde_json::Value; - -use crate::changes::{self, Action}; -use crate::local_commits; -use crate::remote_changes; -use crate::stack_context; -use crate::trunk; - -/// Per-commit summary used by `stack list` / `stack open`. The -/// shape mirrors Python's `StackListEntry.to_dict()` 1:1 so the -/// `--json` output is byte-compatible with the pre-port version. -#[derive(Debug, Clone, Serialize)] -pub struct StackListEntry { - pub commit_sha: String, - pub title: String, - pub change_id: String, - /// `"open" | "draft" | "merged" | "no_pr" | "skipped"`. - pub status: String, - pub pull_number: Option, - pub pull_url: Option, - pub ci_status: String, - pub ci_checks: Vec, - pub review_status: String, - pub reviews: Vec, - pub mergeable: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CiCheck { - pub name: String, - pub status: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Review { - pub user: String, - pub state: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct StackListOutput { - pub branch: String, - pub trunk: String, - pub entries: Vec, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub client: &'a HttpClient, - pub user: &'a str, - pub repo: &'a str, - pub author: &'a str, - pub branch_prefix: &'a str, - pub trunk: (&'a str, &'a str), - /// When false, skip the `/check-runs` and `/reviews` fetches - /// — `stack open` uses this since it only needs the URL. - pub include_status: bool, -} - -pub async fn run(opts: &Options<'_>) -> Result { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let dest_branch = trunk::git_get_branch_name(Some(&repo_dir))?; - stack_context::check_local_branch(&dest_branch, opts.branch_prefix)?; - - let (remote, base_branch) = opts.trunk; - if base_branch == dest_branch { - return Err(stack_context::targets_itself_error( - Some(&repo_dir), - &dest_branch, - remote, - base_branch, - )); - } - - let stack_prefix = if opts.branch_prefix.is_empty() { - dest_branch.clone() - } else { - format!("{prefix}/{dest_branch}", prefix = opts.branch_prefix) - }; - - let trunk_ref = format!("{remote}/{base_branch}"); - let base_commit_sha = - match run_git_capture(Some(&repo_dir), &["merge-base", "--fork-point", &trunk_ref]) { - Ok(sha) if !sha.is_empty() => sha, - _ => run_git_capture(Some(&repo_dir), &["merge-base", &trunk_ref, "HEAD"])?, - }; - if base_commit_sha.is_empty() { - return Err(CliError::StackNotFound(format!( - "common commit between `{trunk_ref}` and `{dest_branch}` branches not found" - ))); - } - - let local = local_commits::read(&repo_dir, &base_commit_sha, "HEAD")?; - let remote_changes = remote_changes::get_remote_changes( - opts.client, - opts.user, - opts.repo, - &stack_prefix, - Some(opts.author), - &local, - ) - .await?; - let classified = changes::classify(&local, remote_changes)?; - - let mut entries = Vec::with_capacity(classified.locals.len()); - for local in &classified.locals { - let (status, pull_number, pull_url) = match &local.pull { - None => ("no_pr".to_string(), None, None), - Some(pull) => { - let merged = pull.get("merged_at").is_some_and(|v| !v.is_null()); - let draft = pull.get("draft").and_then(Value::as_bool).unwrap_or(false); - let status = if matches!(local.action, Action::SkipMerged) || merged { - "merged" - } else if draft { - "draft" - } else { - "open" - }; - let pull_number = pull.get("number").and_then(Value::as_u64); - let pull_url = pull - .get("html_url") - .and_then(Value::as_str) - .map(str::to_owned); - (status.to_string(), pull_number, pull_url) - } - }; - entries.push(StackListEntry { - commit_sha: local.commit_sha.clone(), - title: local.title.clone(), - change_id: local.change_id.clone(), - status, - pull_number, - pull_url, - ci_status: "unknown".to_string(), - ci_checks: Vec::new(), - review_status: "unknown".to_string(), - reviews: Vec::new(), - mergeable: None, - }); - } - - if opts.include_status { - fetch_pr_details( - opts.client, - opts.user, - opts.repo, - &classified.locals, - &mut entries, - ) - .await?; - } - - Ok(StackListOutput { - branch: dest_branch, - trunk: trunk_ref, - entries, - }) -} - -/// Per-PR fan-out: for each entry with a pull, fetch -/// `/check-runs` + `/reviews` and fold them into the entry's -/// `ci_*` / `review_*` fields, plus the PR itself for `mergeable` -/// unless it merged — the discovery payloads come from the -/// pull-request list endpoint, which never carries it (`None` while -/// GitHub is still computing it). Sequential rather than concurrent -/// since GitHub's secondary rate limit kicks in around ~80 -/// concurrent calls on the same endpoint pool — and a typical -/// stack has well under that. -async fn fetch_pr_details( - client: &HttpClient, - user: &str, - repo: &str, - classified: &[crate::changes::LocalChange], - entries: &mut [StackListEntry], -) -> Result<(), CliError> { - for (i, change) in classified.iter().enumerate() { - let Some(pull) = &change.pull else { continue }; - let Some(head_sha) = pull.pointer("/head/sha").and_then(Value::as_str) else { - continue; - }; - let Some(pull_number) = pull.get("number").and_then(Value::as_u64) else { - continue; - }; - - let checks_path = format!("/repos/{user}/{repo}/commits/{head_sha}/check-runs"); - let reviews_path = format!("/repos/{user}/{repo}/pulls/{pull_number}/reviews"); - - let checks_payload: Value = client.get(&checks_path).await?; - let reviews_payload: Value = client.get(&reviews_path).await?; - - let check_runs: Vec = checks_payload - .get("check_runs") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let (ci_status, ci_checks) = compute_ci_status(&check_runs); - entries[i].ci_status = ci_status.to_string(); - entries[i].ci_checks = ci_checks; - - let reviews: Vec = reviews_payload.as_array().cloned().unwrap_or_default(); - let (review_status, review_list) = compute_review_status(&reviews); - entries[i].review_status = review_status.to_string(); - entries[i].reviews = review_list; - - if entries[i].status != "merged" { - let full: Value = client - .get(&format!("/repos/{user}/{repo}/pulls/{pull_number}")) - .await?; - entries[i].mergeable = full.get("mergeable").and_then(Value::as_bool); - } - } - Ok(()) -} - -fn compute_ci_status(check_runs: &[Value]) -> (&'static str, Vec) { - if check_runs.is_empty() { - return ("unknown", Vec::new()); - } - let mut checks = Vec::with_capacity(check_runs.len()); - let mut has_pending = false; - let mut has_failure = false; - for run in check_runs { - let name = run - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(); - let status = run.get("status").and_then(Value::as_str); - let conclusion = run.get("conclusion").and_then(Value::as_str); - let kind = if status != Some("completed") { - has_pending = true; - "pending" - } else if matches!(conclusion, Some("success" | "skipped")) { - "success" - } else { - has_failure = true; - "failure" - }; - checks.push(CiCheck { - name, - status: kind.to_string(), - }); - } - let overall = if has_failure { - "failing" - } else if has_pending { - "pending" - } else { - "passing" - }; - (overall, checks) -} - -fn compute_review_status(reviews: &[Value]) -> (&'static str, Vec) { - if reviews.is_empty() { - return ("unknown", Vec::new()); - } - // Mirrors Python: keep the latest per user; APPROVED / - // CHANGES_REQUESTED / DISMISSED override an earlier COMMENTED. - let mut latest_by_user: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - for review in reviews { - let user = review - .pointer("/user/login") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - if user.is_empty() { - continue; - } - let state = review - .get("state") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let promoting = matches!( - state.as_str(), - "APPROVED" | "CHANGES_REQUESTED" | "DISMISSED" - ); - if promoting || !latest_by_user.contains_key(&user) { - latest_by_user.insert(user, state); - } - } - let list: Vec = latest_by_user - .into_iter() - .map(|(user, state)| Review { user, state }) - .collect(); - let has_changes = list.iter().any(|r| r.state == "CHANGES_REQUESTED"); - let has_approved = list.iter().any(|r| r.state == "APPROVED"); - let overall = if has_changes { - "changes_requested" - } else if has_approved { - "approved" - } else { - "pending" - }; - (overall, list) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn compute_ci_status_classifies_passing() { - let runs = vec![ - json!({"name": "ci-1", "status": "completed", "conclusion": "success"}), - json!({"name": "ci-2", "status": "completed", "conclusion": "skipped"}), - ]; - let (status, checks) = compute_ci_status(&runs); - assert_eq!(status, "passing"); - assert_eq!(checks.len(), 2); - assert!(checks.iter().all(|c| c.status == "success")); - } - - #[test] - fn compute_ci_status_classifies_failing_over_pending() { - let runs = vec![ - json!({"name": "ci-1", "status": "in_progress"}), - json!({"name": "ci-2", "status": "completed", "conclusion": "failure"}), - ]; - let (status, _) = compute_ci_status(&runs); - assert_eq!(status, "failing"); - } - - #[test] - fn compute_ci_status_classifies_pending() { - let runs = vec![json!({"name": "ci-1", "status": "queued"})]; - let (status, _) = compute_ci_status(&runs); - assert_eq!(status, "pending"); - } - - #[test] - fn compute_review_status_latest_per_user_promoting_states() { - let reviews = vec![ - json!({"user": {"login": "alice"}, "state": "COMMENTED"}), - json!({"user": {"login": "alice"}, "state": "APPROVED"}), - json!({"user": {"login": "bob"}, "state": "CHANGES_REQUESTED"}), - ]; - let (status, list) = compute_review_status(&reviews); - assert_eq!(status, "changes_requested"); - assert_eq!(list.len(), 2); - let by_user: std::collections::HashMap<_, _> = - list.into_iter().map(|r| (r.user, r.state)).collect(); - assert_eq!(by_user["alice"], "APPROVED"); - assert_eq!(by_user["bob"], "CHANGES_REQUESTED"); - } - - #[test] - fn compute_review_status_approved_when_no_changes_requested() { - let reviews = vec![json!({"user": {"login": "alice"}, "state": "APPROVED"})]; - let (status, _) = compute_review_status(&reviews); - assert_eq!(status, "approved"); - } - - #[test] - fn compute_review_status_pending_when_only_comments() { - let reviews = vec![json!({"user": {"login": "alice"}, "state": "COMMENTED"})]; - let (status, _) = compute_review_status(&reviews); - assert_eq!(status, "pending"); - } -} diff --git a/crates/mergify-stack/src/commands/mod.rs b/crates/mergify-stack/src/commands/mod.rs deleted file mode 100644 index ff7d4eae..00000000 --- a/crates/mergify-stack/src/commands/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Native implementations of `mergify stack `. One -//! module per subcommand. The `Stack(StackArgs)` variant in the -//! main binary dispatches into here for ported subcommands; the -//! rest still shim to Python. - -pub mod checkout; -pub mod drop; -pub mod edit; -pub mod fixup; -pub mod list; -#[path = "move_cmd.rs"] -pub mod move_cmd; -pub mod new; -pub mod note; -pub mod open; -pub mod push; -pub mod reorder; -pub mod reword; -pub mod setup; -pub mod squash; -pub mod sync; diff --git a/crates/mergify-stack/src/commands/move_cmd.rs b/crates/mergify-stack/src/commands/move_cmd.rs deleted file mode 100644 index 70aacfb6..00000000 --- a/crates/mergify-stack/src/commands/move_cmd.rs +++ /dev/null @@ -1,239 +0,0 @@ -//! `mergify stack move [] [--dry-run]` -//! — reposition a single commit within the stack. Wraps the -//! reorder machinery: compute the new order from the current -//! stack + the requested position, then delegate to the same -//! `Action::Reorder` path. -//! -//! Port of `mergify_cli/stack/move.py::stack_move`. - -use std::path::Path; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -/// Where to move the commit. Mirrors Python's positional value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Position { - First, - Last, - Before, - After, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrderedCommit { - sha: String, - subject: String, - change_id: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Move ran to completion. `plan` is the new full-stack order - /// (tag-less, like Python's `display_plan`). - Moved { - plan: Vec, - }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { - plan: Vec, - }, - AlreadyInPosition, - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefix: &'a str, - pub position: Position, - /// Required when `position` is `Before` / `After`; must be - /// `None` for `First` / `Last`. - pub target_prefix: Option<&'a str>, - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -pub fn run(opts: &Options<'_>) -> Result { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let commit = match_commit(opts.commit_prefix, &commits)?; - - let target = match (&opts.position, opts.target_prefix) { - (Position::Before | Position::After, None) => { - return Err(CliError::InvalidState(format!( - "'{name}' requires a target commit", - name = position_name(&opts.position) - ))); - } - (Position::Before | Position::After, Some(prefix)) => { - let resolved = match_commit(prefix, &commits)?; - if resolved.sha == commit.sha { - return Err(CliError::InvalidState( - "commit and target are the same".to_string(), - )); - } - Some(resolved) - } - (Position::First | Position::Last, Some(_)) => { - return Err(CliError::InvalidState(format!( - "'{name}' does not accept a target commit", - name = position_name(&opts.position) - ))); - } - (Position::First | Position::Last, None) => None, - }; - - let new_order = compute_new_order(&commits, &commit, &opts.position, target.as_ref()); - - let current_shas: Vec<&str> = commits.iter().map(|c| c.commit_sha.as_str()).collect(); - let new_shas: Vec<&str> = new_order.iter().map(|c| c.sha.as_str()).collect(); - if current_shas == new_shas { - return Ok(Outcome::AlreadyInPosition); - } - - let plan: Vec = new_order - .iter() - .map(|c| PlanRow { - sha: c.sha.clone(), - subject: c.subject.clone(), - change_id: c.change_id.clone(), - action: None, - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - spawn_reorder_rebase(&repo_dir, &base, opts.mergify_binary, &new_shas)?; - Ok(Outcome::Moved { plan }) -} - -/// Build the reordered stack: every commit except `commit` in its -/// original order, with `commit` reinserted at the requested -/// `position` (relative to `target` for `Before` / `After`). -fn compute_new_order( - commits: &[LocalCommit], - commit: &OrderedCommit, - position: &Position, - target: Option<&OrderedCommit>, -) -> Vec { - let remaining: Vec = commits - .iter() - .filter(|c| c.commit_sha != commit.sha) - .map(|c| OrderedCommit { - sha: c.commit_sha.clone(), - subject: c.title.clone(), - change_id: c.change_id.clone(), - }) - .collect(); - - match position { - Position::First => { - let mut v = Vec::with_capacity(remaining.len() + 1); - v.push(commit.clone()); - v.extend(remaining); - v - } - Position::Last => { - let mut v = remaining; - v.push(commit.clone()); - v - } - Position::Before | Position::After => { - let target = target.expect("target validated above"); - let idx = remaining - .iter() - .position(|c| c.sha == target.sha) - .expect("target was in stack"); - // `Before` inserts at the target's index; `After` - // inserts just past it. - let insert_at = if matches!(position, Position::After) { - idx + 1 - } else { - idx - }; - let mut v = Vec::with_capacity(remaining.len() + 1); - v.extend(remaining[..insert_at].iter().cloned()); - v.push(commit.clone()); - v.extend(remaining[insert_at..].iter().cloned()); - v - } - } -} - -fn position_name(p: &Position) -> &'static str { - match p { - Position::First => "first", - Position::Last => "last", - Position::Before => "before", - Position::After => "after", - } -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(OrderedCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - change_id: only.change_id.clone(), - }), - many => { - let candidates: Vec> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn spawn_reorder_rebase( - repo_dir: &Path, - base: &str, - mergify_binary: &Path, - ordered_shas: &[&str], -) -> Result<(), CliError> { - let bin = shell_quote(&mergify_binary.to_string_lossy()); - let shas = shell_quote(&ordered_shas.join(",")); - let editor = format!("{bin} _internal rebase-todo-rewrite --action reorder --shas {shas}"); - spawn_rebase(repo_dir, base, Some(&editor), false) -} diff --git a/crates/mergify-stack/src/commands/new.rs b/crates/mergify-stack/src/commands/new.rs deleted file mode 100644 index 3105c0b2..00000000 --- a/crates/mergify-stack/src/commands/new.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! `mergify stack new ` — create a new stack branch tracking -//! the resolved trunk (or an explicit `--base /`). -//! Port of `mergify_cli/stack/new.py::stack_new`. - -use std::path::Path; - -use crate::git::run_git_silent as run_git; - -use mergify_core::CliError; - -use crate::trunk; - -/// Base ref the new branch should fork from. `None` means "resolve -/// it via [`trunk::get_trunk`] using the current branch's tracking -/// info or `origin/HEAD`". -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Base { - pub remote: String, - pub branch: String, -} - -impl Base { - fn refspec(&self) -> String { - format!("{}/{}", self.remote, self.branch) - } -} - -/// Side-effect log we surface to the human-mode caller so it can -/// render the same messages the Python implementation did. Keeps -/// `run` itself printing-free (callers handle stdout/stderr). -#[derive(Debug, Default, Clone)] -pub struct Outcome { - /// Set when `get_trunk` had to write `branch..{remote,merge}` - /// — Python prints a yellow "Upstream not set …" notice in - /// that case. - pub upstream_auto_set: Option, - pub branch_name: String, - pub base_refspec: String, - pub checked_out: bool, -} - -#[derive(Debug, Clone)] -pub struct UpstreamAutoSet { - pub current_branch: String, - pub remote: String, - pub branch: String, -} - -/// Create a new stack branch. -/// -/// `name` — the branch to create. -/// `base` — `Some((remote, branch))` to fork from an explicit ref, -/// `None` to resolve the trunk (and lazily set tracking on the -/// current branch as a side effect, mirroring Python). -/// `checkout` — when true, `git checkout --track -b`; otherwise -/// `git branch --track` and the user is left on the original -/// branch. -/// -/// Errors: -/// - [`CliError::StackNotFound`] if the trunk can't be resolved -/// (no upstream tracking AND no `origin/HEAD`). Matches Python's -/// `sys.exit(ExitCode.STACK_NOT_FOUND)`. -/// - [`CliError::Generic`] for `git fetch` / `git checkout` / -/// `git branch` failures. Matches Python's -/// `sys.exit(ExitCode.GENERIC_ERROR)` after a failed branch -/// create, plus the raise on a failed fetch. -pub fn run( - repo_dir: Option<&Path>, - name: &str, - base: Option, - checkout: bool, -) -> Result { - let (base, upstream_auto_set) = if let Some(b) = base { - (b, None) - } else { - let trunk = trunk::get_trunk(repo_dir).map_err(|e| { - // Preserve the underlying error so users see *why* the - // trunk couldn't be resolved (missing `origin/HEAD`, - // failed `git branch --set-upstream-to`, not a git - // repo, …). The wrapper exit code stays - // `STACK_NOT_FOUND`, matching the Python flow. - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or use --base to specify the base \ - branch." - )) - })?; - let auto_set = trunk.tracking_set.then(|| UpstreamAutoSet { - current_branch: trunk.current_branch.clone(), - remote: trunk.remote.clone(), - branch: trunk.branch.clone(), - }); - ( - Base { - remote: trunk.remote, - branch: trunk.branch, - }, - auto_set, - ) - }; - - run_git(repo_dir, &["fetch", &base.remote, &base.branch]).map_err(|e| { - CliError::Generic(format!( - "failed to fetch from {remote}: {e}", - remote = base.remote - )) - })?; - - let base_ref = base.refspec(); - let create_args: Vec<&str> = if checkout { - vec!["checkout", "--track", "-b", name, &base_ref] - } else { - vec!["branch", "--track", name, &base_ref] - }; - run_git(repo_dir, &create_args) - .map_err(|e| CliError::Generic(format!("failed to create branch '{name}': {e}")))?; - - Ok(Outcome { - upstream_auto_set, - branch_name: name.to_string(), - base_refspec: base_ref, - checked_out: checkout, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command as StdCommand; - use tempfile::TempDir; - - /// Build `` containing two repos: - /// - `upstream.git` — a bare repo with `main` (one commit) - /// - `local` — a clone of `upstream.git` with `origin` wired up - fn make_repo_pair() -> TempDir { - let workdir = tempfile::tempdir().unwrap(); - - let upstream = workdir.path().join("upstream.git"); - run_status(&[ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]); - - let seed = workdir.path().join("seed"); - std::fs::create_dir(&seed).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - ] { - run_in(&seed, args); - } - - let local = workdir.path().join("local"); - run_status(&[ - "clone", - "-q", - upstream.to_str().unwrap(), - local.to_str().unwrap(), - ]); - run_in(&local, &["config", "user.email", "t@e.com"]); - run_in(&local, &["config", "user.name", "T"]); - workdir - } - - fn local_path(workdir: &Path) -> std::path::PathBuf { - workdir.join("local") - } - - fn run_in(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {dir:?} {args:?} failed"); - } - - fn run_status(args: &[&str]) { - let ok = crate::test_env::isolated_git() - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?} failed"); - } - - fn branch_exists(dir: &Path, name: &str) -> bool { - crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(["rev-parse", "--verify", "--quiet", name]) - .status() - .unwrap() - .success() - } - - fn current_branch(dir: &Path) -> String { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[test] - fn creates_branch_with_checkout_using_trunk() { - let work = make_repo_pair(); - let local = local_path(work.path()); - - let outcome = run(Some(&local), "feature-x", None, true).unwrap(); - assert_eq!(outcome.branch_name, "feature-x"); - assert_eq!(outcome.base_refspec, "origin/main"); - assert!(outcome.checked_out); - assert!(branch_exists(&local, "feature-x")); - assert_eq!(current_branch(&local), "feature-x"); - } - - #[test] - fn creates_branch_without_checkout() { - let work = make_repo_pair(); - let local = local_path(work.path()); - - let outcome = run(Some(&local), "feature-y", None, false).unwrap(); - assert!(!outcome.checked_out); - assert!(branch_exists(&local, "feature-y")); - assert_eq!(current_branch(&local), "main"); - } - - #[test] - fn creates_branch_from_explicit_base() { - let work = make_repo_pair(); - let local = local_path(work.path()); - // Add a second branch to the upstream so we have a non-main - // base to fork from. - let upstream = work.path().join("upstream.git"); - // Push a "develop" branch from the local clone. - run_in(&local, &["checkout", "-q", "-b", "develop"]); - run_in(&local, &["commit", "--allow-empty", "-m", "dev"]); - run_in(&local, &["push", "-q", "origin", "develop"]); - run_in(&local, &["checkout", "-q", "main"]); - run_in(&local, &["branch", "-q", "-D", "develop"]); - // sanity - assert!( - StdCommand::new("git") - .arg("-C") - .arg(&upstream) - .args(["rev-parse", "--verify", "develop"]) - .status() - .unwrap() - .success() - ); - - let outcome = run( - Some(&local), - "feature-z", - Some(Base { - remote: "origin".to_string(), - branch: "develop".to_string(), - }), - true, - ) - .unwrap(); - assert_eq!(outcome.base_refspec, "origin/develop"); - assert!(branch_exists(&local, "feature-z")); - } - - #[test] - fn errors_when_branch_already_exists() { - let work = make_repo_pair(); - let local = local_path(work.path()); - run_in(&local, &["branch", "existing"]); - - let err = run(Some(&local), "existing", None, true).unwrap_err(); - match err { - CliError::Generic(msg) => { - assert!( - msg.contains("already exists") || msg.contains("existing"), - "unexpected error: {msg}" - ); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn errors_when_trunk_cannot_be_resolved() { - // Repo with no remotes at all -> origin/HEAD doesn't exist - // and the current branch has no upstream. - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - ] { - run_in(dir.path(), args); - } - - let err = run(Some(dir.path()), "feature", None, true).unwrap_err(); - match err { - CliError::StackNotFound(msg) => { - assert!(msg.contains("could not determine trunk branch")); - } - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/commands/note.rs b/crates/mergify-stack/src/commands/note.rs deleted file mode 100644 index 0a609020..00000000 --- a/crates/mergify-stack/src/commands/note.rs +++ /dev/null @@ -1,612 +0,0 @@ -//! `mergify stack note [] [-m ] [--append] [--remove]` -//! — attach a "why was this commit amended" note to a commit on -//! `refs/notes/mergify/stack`. Port of -//! `mergify_cli/stack/note.py::stack_note`. -//! -//! The note ref is what `mergify stack push` reads when it renders -//! a PR's "Revision history" comment — see the `note` field on -//! [`crate::local_commits::LocalCommit`] for the read-side -//! counterpart. - -use std::ffi::OsString; -use std::io::{Read, Write}; -use std::path::Path; -use std::process::Command; - -use crate::git::{resolve_repo_toplevel, run_git_capture as run_git}; - -use mergify_core::CliError; -use mergify_core::env; - -use crate::change_id; -use crate::local_commits::{self, STACK_NOTES_REF}; -use crate::trunk; - -const EDITOR_TEMPLATE: &str = - "\n# Why was this commit amended? Lines starting with # are ignored.\n"; - -/// What [`run`] should do with the target commit's note. -#[derive(Debug, Clone)] -pub enum Action { - /// Replace any existing note with `message`. The default - /// for `mergify stack note -m "…"` and the post-editor path. - Set(String), - /// Concatenate `message` to the existing note with a blank - /// line separator (`git notes append` semantics). - Append(String), - /// Read the note from `$GIT_EDITOR` (then `VISUAL`, `EDITOR`, - /// `vi`) using a template that includes the "lines starting - /// with # are ignored" hint. - FromEditor, - /// Remove the existing note. No-op (and returns - /// [`Outcome::NoNoteToRemove`]) when the commit has no note. - Remove, -} - -/// Result of [`run`] — callers print one human line per variant. -#[derive(Debug, Clone)] -pub enum Outcome { - /// Note attached/replaced/appended successfully. - Attached { sha: String, subject: String }, - /// `--remove` was given and a note existed before; now gone. - Removed { sha: String, subject: String }, - /// `--remove` was given on a commit with no note. Python prints - /// `No note on .` and exits 0; mirror that. - NoNoteToRemove { sha: String, subject: String }, -} - -/// Attach, append, or remove the note on the target commit. -/// -/// `commit` resolution chain (mirrors Python): -/// - `None` → `HEAD` -/// - looks like a Change-Id prefix (`I[0-9a-f]+`) → walk the stack -/// (`..HEAD`) and match by `change_id` -/// - otherwise → `git rev-parse --verify ^{commit}` -/// -/// Errors: -/// - [`CliError::InvalidState`] for an empty message (inline `-m ""` -/// or editor returning only comment lines) and for an ambiguous -/// Change-Id prefix match (exit 7). -/// - [`CliError::StackNotFound`] when a Change-Id prefix matches no -/// commit in the stack (exit 3) — matches Python's -/// `sys.exit(ExitCode.STACK_NOT_FOUND)`. -/// - [`CliError::Generic`] for git invocation failures and editor -/// process errors. -pub fn run( - repo_dir: Option<&Path>, - commit: Option<&str>, - action: Action, -) -> Result { - let (sha, subject) = resolve_commit(repo_dir, commit)?; - - match action { - Action::Remove => { - // Mirror Python: if there's no note, print a message - // and exit 0; otherwise remove and confirm. - if note_exists(repo_dir, &sha)? { - run_git(repo_dir, &["notes", ¬es_ref_arg(), "remove", &sha])?; - Ok(Outcome::Removed { sha, subject }) - } else { - Ok(Outcome::NoNoteToRemove { sha, subject }) - } - } - Action::FromEditor => { - let message = read_note_from_editor()?; - attach_note(repo_dir, &sha, &message, /*append=*/ false)?; - Ok(Outcome::Attached { sha, subject }) - } - Action::Set(message) => { - let message = sanitize_message(&message)?; - attach_note(repo_dir, &sha, &message, /*append=*/ false)?; - Ok(Outcome::Attached { sha, subject }) - } - Action::Append(message) => { - let message = sanitize_message(&message)?; - attach_note(repo_dir, &sha, &message, /*append=*/ true)?; - Ok(Outcome::Attached { sha, subject }) - } - } -} - -fn sanitize_message(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliError::InvalidState( - "note is empty, nothing attached.".to_string(), - )); - } - Ok(trimmed.to_string()) -} - -fn notes_ref_arg() -> String { - format!("--ref={STACK_NOTES_REF}") -} - -fn attach_note( - repo_dir: Option<&Path>, - sha: &str, - message: &str, - append: bool, -) -> Result<(), CliError> { - let notes_ref = notes_ref_arg(); - let verb = if append { "append" } else { "add" }; - let mut argv: Vec<&str> = vec!["notes", ¬es_ref, verb]; - // Python passes `-f` unconditionally on the set path so the - // `add` command replaces any existing note instead of erroring. - if !append { - argv.push("-f"); - } - argv.extend_from_slice(&["-m", message, sha]); - run_git(repo_dir, &argv).map(|_| ()) -} - -fn note_exists(repo_dir: Option<&Path>, sha: &str) -> Result { - let notes_ref = notes_ref_arg(); - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - cmd.args(["notes", ¬es_ref, "show", sha]); - let output = cmd - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git notes show`: {e}")))?; - Ok(output.status.success()) -} - -fn resolve_commit( - repo_dir: Option<&Path>, - commit: Option<&str>, -) -> Result<(String, String), CliError> { - let sha = match commit { - None => run_git(repo_dir, &["rev-parse", "--verify", "HEAD^{commit}"])?, - Some(value) if change_id::is_prefix(value) => resolve_change_id_prefix(repo_dir, value)?, - Some(value) => { - let spec = format!("{value}^{{commit}}"); - run_git(repo_dir, &["rev-parse", "--verify", &spec])? - } - }; - let subject = run_git(repo_dir, &["log", "-1", "--format=%s", &sha])?; - Ok((sha, subject)) -} - -fn resolve_change_id_prefix(repo_dir: Option<&Path>, prefix: &str) -> Result { - let resolved_repo = match repo_dir { - Some(dir) => dir.to_path_buf(), - None => resolve_repo_toplevel(None)?, - }; - let trunk = trunk::get_trunk(Some(&resolved_repo)) - .map_err(|e| CliError::Generic(format!("couldn't resolve trunk to walk the stack: {e}")))?; - let base = run_git( - Some(&resolved_repo), - &["merge-base", &trunk.refspec(), "HEAD"], - )?; - let commits = local_commits::read(&resolved_repo, &base, "HEAD")?; - let matches: Vec<&local_commits::LocalCommit> = commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(); - match matches.as_slice() { - [] => Err(crate::match_commit::not_found("Change-Id", prefix)), - [only] => Ok(only.commit_sha.clone()), - many => { - let candidates: Vec> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous( - "Change-Id", - prefix, - &candidates, - )) - } - } -} - -/// Open the user's editor on a tempfile pre-filled with the -/// comment template, then read it back and strip comment lines. -/// Mirrors Python's `_read_note_from_editor`. -fn read_note_from_editor() -> Result { - // Treat empty env-var values as unset so `GIT_EDITOR=` falls - // through to `$VISUAL` / `$EDITOR` / `vi` instead of spawning - // an empty command. Matches Python's `or`-chain semantics. - let editor = env::var_os_non_empty("GIT_EDITOR") - .or_else(|| env::var_os_non_empty("VISUAL")) - .or_else(|| env::var_os_non_empty("EDITOR")) - .unwrap_or_else(|| OsString::from("vi")); - - let mut tmp = tempfile::Builder::new() - .prefix("mergify_note_") - .suffix(".txt") - .tempfile() - .map_err(|e| CliError::Generic(format!("create editor tempfile: {e}")))?; - tmp.write_all(EDITOR_TEMPLATE.as_bytes()) - .map_err(|e| CliError::Generic(format!("write editor template: {e}")))?; - tmp.flush() - .map_err(|e| CliError::Generic(format!("flush editor template: {e}")))?; - let path = tmp.into_temp_path(); - - // Python uses `shlex.split(editor)` then exec — accept editor - // strings that include args ("code -w" etc.). Approximate via - // `/bin/sh -c " "` on Unix, and `cmd /c …` on - // Windows; this is what `git` itself does to honor compound - // editor strings. - let status = invoke_editor(&editor, path.to_string_lossy().as_ref())?; - if !status.success() { - return Err(CliError::Generic(format!( - "editor {} exited with status {status:?}", - editor.display() - ))); - } - - let mut buf = String::new(); - std::fs::File::open(&path) - .and_then(|mut f| f.read_to_string(&mut buf)) - .map_err(|e| CliError::Generic(format!("read editor tempfile: {e}")))?; - - // Drop comment lines, mirror Python. - let cleaned: String = buf - .lines() - .filter(|l| !l.trim_start().starts_with('#')) - .collect::>() - .join("\n"); - let cleaned = cleaned.trim().to_string(); - if cleaned.is_empty() { - return Err(CliError::InvalidState( - "note is empty, nothing attached.".to_string(), - )); - } - Ok(cleaned) -} - -#[cfg(unix)] -fn invoke_editor(editor: &OsString, path: &str) -> Result { - let cmd_line = format!("{} \"$@\"", editor.to_string_lossy()); - Command::new("sh") - .arg("-c") - .arg(&cmd_line) - .arg("sh") - .arg(path) - .status() - .map_err(|e| CliError::Generic(format!("spawn editor: {e}"))) -} - -#[cfg(windows)] -fn invoke_editor(editor: &OsString, path: &str) -> Result { - // Tempfile paths commonly contain spaces (e.g. `C:\Users\Foo - // Bar\AppData\Local\Temp\…`), so we cannot just concatenate - // the editor string and the path verbatim — cmd.exe would - // split on the space and hand the editor a truncated path. - // Wrap the path in `""` so cmd treats it as a single token; - // the editor itself sees the unquoted path through argv. - let cmd_line = format!("{} \"{}\"", editor.to_string_lossy(), path); - Command::new("cmd") - .args(["/C", &cmd_line]) - .status() - .map_err(|e| CliError::Generic(format!("spawn editor: {e}"))) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - ] { - run_in(dir.path(), args); - } - dir - } - - fn run_in(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git -C {dir:?} {args:?} failed"); - } - - fn read_note(dir: &Path, sha: &str) -> Option { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(["notes", "--ref=refs/notes/mergify/stack", "show", sha]) - .output() - .unwrap(); - if out.status.success() { - Some(String::from_utf8(out.stdout).unwrap().trim().to_string()) - } else { - None - } - } - - fn head_sha(dir: &Path) -> String { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(["rev-parse", "HEAD"]) - .output() - .unwrap(); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[cfg(unix)] - fn set_executable(path: &Path) { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); - } - - #[test] - fn add_attaches_note_to_head() { - let dir = init_repo(); - let outcome = run(Some(dir.path()), None, Action::Set("fixed a typo".into())).unwrap(); - assert!(matches!(outcome, Outcome::Attached { .. })); - assert_eq!( - read_note(dir.path(), &head_sha(dir.path())).as_deref(), - Some("fixed a typo") - ); - } - - #[test] - fn add_to_specific_sha_via_prefix() { - let dir = init_repo(); - let first = head_sha(dir.path()); - run_in(dir.path(), &["commit", "--allow-empty", "-m", "second"]); - - let outcome = run( - Some(dir.path()), - Some(&first[..10]), - Action::Set("note for first".into()), - ) - .unwrap(); - assert!(matches!(outcome, Outcome::Attached { .. })); - assert_eq!( - read_note(dir.path(), &first).as_deref(), - Some("note for first") - ); - } - - #[test] - fn append_concatenates_with_blank_line() { - let dir = init_repo(); - run(Some(dir.path()), None, Action::Set("first line".into())).unwrap(); - run(Some(dir.path()), None, Action::Append("second line".into())).unwrap(); - assert_eq!( - read_note(dir.path(), &head_sha(dir.path())).as_deref(), - Some("first line\n\nsecond line") - ); - } - - #[test] - fn replace_is_default() { - let dir = init_repo(); - run(Some(dir.path()), None, Action::Set("first".into())).unwrap(); - run(Some(dir.path()), None, Action::Set("second".into())).unwrap(); - assert_eq!( - read_note(dir.path(), &head_sha(dir.path())).as_deref(), - Some("second") - ); - } - - #[test] - fn remove_deletes_note() { - let dir = init_repo(); - run(Some(dir.path()), None, Action::Set("doomed".into())).unwrap(); - let outcome = run(Some(dir.path()), None, Action::Remove).unwrap(); - assert!(matches!(outcome, Outcome::Removed { .. })); - assert!(read_note(dir.path(), &head_sha(dir.path())).is_none()); - } - - #[test] - fn remove_is_idempotent() { - let dir = init_repo(); - let outcome = run(Some(dir.path()), None, Action::Remove).unwrap(); - assert!(matches!(outcome, Outcome::NoNoteToRemove { .. })); - } - - #[test] - fn empty_inline_message_is_rejected() { - let dir = init_repo(); - let err = run(Some(dir.path()), None, Action::Set(" ".into())).unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("note is empty"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - // No note was written. - assert!(read_note(dir.path(), &head_sha(dir.path())).is_none()); - } - - /// Editor-fallback parity regression. The Python code stripped - /// comment lines and trimmed the surrounding whitespace before - /// writing the note; we mirror that here so a user's - /// `# Why was this commit amended? …` template line never - /// leaks into the stored note. - #[cfg(unix)] - #[test] - fn editor_fallback_strips_comments_and_writes_real_note() { - let dir = init_repo(); - let editor = dir.path().join("fake-editor.sh"); - std::fs::write( - &editor, - "#!/bin/sh\nprintf 'real note text\\n# template line\\n' > \"$1\"\n", - ) - .unwrap(); - set_executable(&editor); - - env::testing::with_var("GIT_EDITOR", Some(editor.to_str().unwrap()), || { - run(Some(dir.path()), None, Action::FromEditor).unwrap(); - }); - assert_eq!( - read_note(dir.path(), &head_sha(dir.path())).as_deref(), - Some("real note text"), - ); - } - - /// Empty editor output (comments only, no real content) is - /// rejected as `InvalidState` — the user accidentally wrote - /// nothing, don't silently attach a blank note. Matches - /// Python's `sys.exit(ExitCode.INVALID_STATE)`. - #[cfg(unix)] - #[test] - fn editor_fallback_rejects_empty_result() { - let dir = init_repo(); - let editor = dir.path().join("empty-editor.sh"); - std::fs::write( - &editor, - "#!/bin/sh\nprintf '# only a comment\\n \\n' > \"$1\"\n", - ) - .unwrap(); - set_executable(&editor); - - let err = env::testing::with_var("GIT_EDITOR", Some(editor.to_str().unwrap()), || { - run(Some(dir.path()), None, Action::FromEditor).unwrap_err() - }); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("note is empty"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - assert!(read_note(dir.path(), &head_sha(dir.path())).is_none()); - } - - /// `GIT_EDITOR=` (empty) must fall through to `$VISUAL` — - /// Python's `or` chain treats empty strings as unset. Without - /// this filter the spawn would fail trying to execute "". - #[cfg(unix)] - #[test] - fn editor_fallback_treats_empty_env_var_as_unset() { - let dir = init_repo(); - let editor = dir.path().join("visual-editor.sh"); - std::fs::write(&editor, "#!/bin/sh\nprintf 'from VISUAL\\n' > \"$1\"\n").unwrap(); - set_executable(&editor); - - env::testing::with_vars( - [ - ("GIT_EDITOR", Some(String::new())), - ("VISUAL", Some(editor.to_str().unwrap().to_string())), - ], - || { - run(Some(dir.path()), None, Action::FromEditor).unwrap(); - }, - ); - assert_eq!( - read_note(dir.path(), &head_sha(dir.path())).as_deref(), - Some("from VISUAL"), - ); - } - - #[test] - fn change_id_prefix_resolves_against_stack() { - // Set up: bare upstream + local clone with one commit on - // `feature` carrying a Change-Id trailer. - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - let local = workdir.path().join("local"); - let ok = crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap() - .success(); - assert!(ok); - - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - // A commit that already carries a Change-Id trailer so the - // local-commits walker accepts it. - let change_id = "Iabcdef0123456789abcdef0123456789abcdef01"; - let msg = format!("feature\n\nChange-Id: {change_id}"); - run_in(&local, &["commit", "--allow-empty", "-m", &msg]); - let target_sha = head_sha(&local); - - let outcome = run( - Some(&local), - Some(&change_id[..9]), - Action::Set("by change-id".into()), - ) - .unwrap(); - assert!(matches!(outcome, Outcome::Attached { .. })); - assert_eq!( - read_note(&local, &target_sha).as_deref(), - Some("by change-id") - ); - } - - #[test] - fn change_id_prefix_with_no_match_errors() { - // Repo with the right upstream wiring but no commit carrying - // a matching Change-Id. - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - let local = workdir.path().join("local"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - run_in(&local, args); - } - let msg = "feature\n\nChange-Id: Idead0000000000000000000000000000000000ff"; - run_in(&local, &["commit", "--allow-empty", "-m", msg]); - - let err = run( - Some(&local), - Some("Ibeef"), - Action::Set("never lands".into()), - ) - .unwrap_err(); - match err { - CliError::StackNotFound(m) => { - assert!(m.contains("no commit found matching Change-Id"), "got: {m}"); - } - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/commands/open.rs b/crates/mergify-stack/src/commands/open.rs deleted file mode 100644 index 72a13d24..00000000 --- a/crates/mergify-stack/src/commands/open.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! `mergify stack open []` — open the PR for a stack -//! commit in the user's default browser. -//! -//! Walks the current stack (via the same machinery `stack list` -//! uses with `include_status=false`), resolves the target commit, -//! then hands the PR URL to the OS's URL-launcher (`open` on -//! macOS, `xdg-open` on Linux, `cmd /C start` on Windows). -//! -//! With no `` argument the binary injects an interactive -//! fuzzy picker (see [`Selector`]) when stdin, stdout, and stderr -//! are all TTYs; otherwise the leaf (HEAD) is opened, which non-TTY -//! callers and scripts rely on. - -use std::path::Path; -use std::process::Command; - -use mergify_core::CliError; -use mergify_core::HttpClient; - -use crate::commands::list::{self, StackListEntry}; - -/// Interactive selection hook: given picker labels and the index -/// to preselect, yield the chosen index (`None` = user cancelled). -/// Injected by the binary — which wires `mergify_tui::fuzzy_select` -/// — so this crate stays testable without a TTY, the same -/// testability-by-parameter pattern as `queue pause`'s `confirm`. -pub type Selector<'a> = &'a dyn Fn(&[String], usize) -> std::io::Result>; - -#[derive(Debug, Clone)] -pub enum Outcome { - /// PR URL was passed to the OS opener. - Opened { - pull_number: u64, - title: String, - pull_url: String, - }, - /// Stack has no commits — nothing to open. - EmptyStack, - /// Interactive picker dismissed with Escape — not an error: the - /// binary prints nothing and exits 0. A normal Ctrl-C never - /// reaches this variant (the picker's SIGINT handler exits 130, - /// the fzf convention); only the defensive fallback for an - /// ignored SIGINT maps an interrupted read here. - Cancelled, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub client: &'a HttpClient, - pub user: &'a str, - pub repo: &'a str, - pub author: &'a str, - pub branch_prefix: &'a str, - pub trunk: (&'a str, &'a str), - /// Commit selector. `None` defaults to the leaf (HEAD). - /// Accepts a full SHA, a SHA prefix, or any git ref the - /// repo understands (`HEAD~1` etc.). - pub commit: Option<&'a str>, - /// Interactive picker used when `commit` is `None`. `None` here - /// (non-TTY callers, tests) falls back to opening the leaf. - pub selector: Option>, -} - -pub async fn run(opts: &Options<'_>) -> Result { - let stack = list::run(&list::Options { - repo_dir: opts.repo_dir, - client: opts.client, - user: opts.user, - repo: opts.repo, - author: opts.author, - branch_prefix: opts.branch_prefix, - trunk: opts.trunk, - // `stack open` doesn't render CI / review status, so skip - // the per-PR fetches to keep the latency low. - include_status: false, - }) - .await?; - - if stack.entries.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let entry = match opts.commit { - None => match opts.selector { - Some(selector) => match pick_interactive(&stack.entries, selector)? { - Some(entry) => entry, - None => return Ok(Outcome::Cancelled), - }, - // Leaf default: pre-picker port behavior, kept for - // non-TTY callers and scripts. The empty-stack guard - // above makes `last()` infallible today; the fallback - // keeps this arm panic-free if that guard ever moves. - None => match stack.entries.last().cloned() { - Some(entry) => entry, - None => return Ok(Outcome::EmptyStack), - }, - }, - Some(commit) => resolve_entry(opts.repo_dir, &stack.entries, commit)?, - }; - - let pull_url = entry.pull_url.ok_or_else(|| { - CliError::StackNotFound(format!( - "No PR for: {title} ({short}) — run `mergify stack push` first.", - title = entry.title, - short = &entry.commit_sha[..entry.commit_sha.len().min(7)], - )) - })?; - let pull_number = entry - .pull_number - .ok_or_else(|| CliError::Generic("entry has pull_url but no pull_number".to_string()))?; - - spawn_opener(&pull_url)?; - - Ok(Outcome::Opened { - pull_number, - title: entry.title, - pull_url, - }) -} - -/// Picker labels, one per stack entry: `# (<sha7>)`, -/// or `(no PR) <title> (<sha7>)` for commits not pushed yet. The -/// no-PR entries stay listed so the picker shows the whole stack -/// shape; selecting one fails downstream with the same "push -/// first" error as the explicit-commit path. -fn build_labels(entries: &[StackListEntry]) -> Vec<String> { - entries - .iter() - .map(|entry| { - let short = &entry.commit_sha[..entry.commit_sha.len().min(7)]; - match entry.pull_number { - Some(number) => format!("#{number} {title} ({short})", title = entry.title), - None => format!("(no PR) {title} ({short})", title = entry.title), - } - }) - .collect() -} - -/// Run the injected picker over the stack, leaf preselected. -/// `Ok(None)` means the user cancelled. Callers guarantee -/// `entries` is non-empty. -fn pick_interactive( - entries: &[StackListEntry], - selector: Selector<'_>, -) -> Result<Option<StackListEntry>, CliError> { - let labels = build_labels(entries); - let picked = selector(&labels, labels.len().saturating_sub(1)) - .map_err(|e| CliError::Generic(format!("interactive selection failed: {e}")))?; - let Some(index) = picked else { - return Ok(None); - }; - let entry = entries.get(index).ok_or_else(|| { - CliError::Generic(format!( - "interactive selection returned out-of-range index {index} for {len} entries", - len = entries.len(), - )) - })?; - Ok(Some(entry.clone())) -} - -fn resolve_entry( - repo_dir: Option<&Path>, - entries: &[StackListEntry], - commit: &str, -) -> Result<StackListEntry, CliError> { - let resolved = resolve_ref_to_sha(repo_dir, commit)?; - entries - .iter() - .find(|e| e.commit_sha == resolved) - .cloned() - .ok_or_else(|| { - CliError::StackNotFound(format!( - "commit `{commit}` ({short}) not found in stack", - short = &resolved[..resolved.len().min(7)], - )) - }) -} - -fn resolve_ref_to_sha(repo_dir: Option<&Path>, commit: &str) -> Result<String, CliError> { - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - cmd.args(["rev-parse", "--verify", commit]); - let out = cmd - .output() - .map_err(|e| CliError::Generic(format!("spawn git rev-parse: {e}")))?; - if !out.status.success() { - return Err(CliError::StackNotFound(format!( - "commit `{commit}` not found" - ))); - } - let sha = String::from_utf8(out.stdout) - .map_err(|e| CliError::Generic(format!("git output not UTF-8: {e}")))? - .trim() - .to_string(); - if sha.is_empty() { - return Err(CliError::StackNotFound(format!( - "commit `{commit}` resolves to empty SHA" - ))); - } - Ok(sha) -} - -#[cfg(target_os = "macos")] -fn spawn_opener(url: &str) -> Result<(), CliError> { - Command::new("open") - .arg(url) - .status() - .map_err(|e| CliError::Generic(format!("failed to spawn `open`: {e}")))?; - Ok(()) -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn spawn_opener(url: &str) -> Result<(), CliError> { - Command::new("xdg-open") - .arg(url) - .status() - .map_err(|e| CliError::Generic(format!("failed to spawn `xdg-open`: {e}")))?; - Ok(()) -} - -#[cfg(windows)] -fn spawn_opener(url: &str) -> Result<(), CliError> { - // `start` is a cmd.exe builtin; the empty `""` placeholder - // tells start the first arg isn't a window title. - Command::new("cmd") - .args(["/C", "start", "", url]) - .status() - .map_err(|e| CliError::Generic(format!("failed to spawn `start`: {e}")))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(title: &str, sha: &str, pull: Option<(u64, &str)>) -> StackListEntry { - StackListEntry { - commit_sha: sha.to_string(), - title: title.to_string(), - change_id: String::new(), - status: pull.map_or("no_pr", |_| "open").to_string(), - pull_number: pull.map(|(number, _)| number), - pull_url: pull.map(|(_, url)| url.to_string()), - ci_status: String::new(), - ci_checks: Vec::new(), - review_status: String::new(), - reviews: Vec::new(), - mergeable: None, - } - } - - fn two_entries() -> Vec<StackListEntry> { - vec![ - entry( - "feat: add API endpoint", - "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - Some((101, "https://github.com/o/r/pull/101")), - ), - entry( - "wip: not pushed yet", - "0123456789abcdef0123456789abcdef01234567", - None, - ), - ] - } - - #[test] - fn labels_show_pr_number_or_no_pr_marker() { - assert_eq!( - build_labels(&two_entries()), - vec![ - "#101 feat: add API endpoint (a1b2c3d)".to_string(), - "(no PR) wip: not pushed yet (0123456)".to_string(), - ], - ); - } - - #[test] - fn picker_preselects_the_leaf_and_returns_the_picked_entry() { - let entries = two_entries(); - let seen_default = std::cell::Cell::new(usize::MAX); - let selector = |labels: &[String], default: usize| { - assert_eq!(labels.len(), 2); - seen_default.set(default); - Ok(Some(0)) - }; - let picked = pick_interactive(&entries, &selector).unwrap().unwrap(); - // Leaf (last entry) is the preselected default… - assert_eq!(seen_default.get(), 1); - // …but the selector's answer wins. - assert_eq!(picked.commit_sha, entries[0].commit_sha); - } - - #[test] - fn cancel_yields_none() { - let selector = |_: &[String], _: usize| Ok(None); - assert!( - pick_interactive(&two_entries(), &selector) - .unwrap() - .is_none() - ); - } - - #[test] - fn selector_io_error_becomes_cli_error() { - let selector = |_: &[String], _: usize| Err(std::io::Error::other("terminal exploded")); - let err = pick_interactive(&two_entries(), &selector).unwrap_err(); - assert!(matches!(err, CliError::Generic(_))); - } - - #[test] - fn out_of_range_selector_index_becomes_cli_error() { - let selector = |_: &[String], _: usize| Ok(Some(99)); - let err = pick_interactive(&two_entries(), &selector).unwrap_err(); - assert!(matches!(err, CliError::Generic(_))); - } -} diff --git a/crates/mergify-stack/src/commands/push.rs b/crates/mergify-stack/src/commands/push.rs deleted file mode 100644 index 8953f948..00000000 --- a/crates/mergify-stack/src/commands/push.rs +++ /dev/null @@ -1,1323 +0,0 @@ -//! Native `mergify stack push` orchestrator. -//! -//! Wires every leaf module ported from `mergify_cli/stack/` into -//! the end-to-end push flow: -//! -//! 1. Resolve git context (branch, repo, stack prefix). -//! 2. Fetch trunk + the `refs/notes/mergify/stack` ref. -//! 3. Walk local commits + remote PRs and plan the actions via -//! [`crate::plan`]. -//! 4. Decide rebase vs. skip via [`crate::approvals`]. -//! 5. Rebase via [`crate::commands::sync`] (or skip). -//! 6. Optionally compute change types + replay SHAs for the -//! revision-history comment. -//! 7. Prepare each Update's revision history and write it as a -//! git note on the new head commit (`crate::revision_note`), -//! so step 8's atomic push carries it. -//! 8. `git push --atomic` via [`crate::notes_push`]. -//! 9. Upsert each PR sequentially via [`crate::pr_upsert`] so -//! each `Depends-On: #<n>` header sees the predecessor's -//! freshly-created PR number. -//! 10. Render + upsert each prepared revision-history comment, per -//! PR via [`crate::comment_upsert`]. -//! 11. Tear down orphan branches. -//! 12. Unless native registration is disabled (`--no-github-native`, -//! or git config `mergify-cli.stack-github-native=false`), bring -//! GitHub's native stack in line with what was just pushed, via -//! [`crate::native_stack`]. -//! 13. Settle what step 12 left open: restore any `Depends-On:` header -//! step 9 left out if the registration did not take, and either -//! take the sticky stack comment down or go on writing it. -//! -//! Step 12 has a conditional step 0. A registered stack blocks one -//! thing: changing a PR's base. So a push that retargets a PR, or -//! tears down an orphan branch (which is only survivable because the -//! survivors were retargeted first), dissolves the registration -//! *before* step 5 and rebuilds it in step 12 — the fence is why the -//! two steps are far apart. A push that only refreshes commits does -//! neither and leaves the registration alone; step 12 then extends it -//! if changes were added on top, and otherwise issues no request at -//! all. See [`crate::native_stack`] for the measured behaviour behind -//! each case. Between the two steps the flow is exactly what it is -//! with native registration disabled, with one exception: step 9 -//! renders the PR bodies without their `Depends-On:` header, because a -//! registered stack is the dependency edge and a second copy of it in -//! prose is the one users read. That is a bet on step 12, which is -//! allowed to quietly not happen — so the headers are held and written -//! back when it doesn't, rather than being dropped on the strength of -//! that setting. -//! -//! Step 13 settles the sticky stack comment on the same bet, for the -//! same reason: a registered stack is listed by GitHub on the pull -//! request page, so our table of the same members is the redundant -//! copy. What differs is which way the work goes. The header is -//! *written back* when the bet fails, because an unregistered, -//! unchained stack merges out of order. The comment is *taken down* -//! when the bet lands, because nothing refreshes it once we stop -//! writing it, and a table frozen at the shape of the last pre-native -//! push is worse under a live list than no table at all. Its failure -//! policy is the opposite one too — a comment that would not delete -//! changes nothing about how the stack merges, so it is reported and -//! retried on the next push rather than failing a push that has -//! already done everything else. -//! -//! The bet is settled inside the push, so the only way to end up with -//! neither the registration nor the headers is a push that *fails* -//! between steps 9 and 12 — and re-running it settles the stack either -//! way, because every push re-renders each body and recomputes the -//! marker from the stack's current shape. That holds whichever text -//! the body is rendered from — the commit message, or the pull -//! request's own body under `--keep-pull-request-title-and-body` -//! (see [`crate::pr_upsert::description_source`]): the marker is -//! stripped and re-appended from the resolved predecessor either way, -//! never carried over from the source text. -//! -//! PR upserts run sequentially. Async here is incidental — it comes -//! from reqwest's async-only client, not from a need for concurrency: -//! for the typical 2–5 PR stacks the latency is dominated by the -//! GitHub round-trip, and sequential code avoids a -//! `tokio::sync::Notify` dependency graph. - -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use crate::git::{compute_base_commit_sha, resolve_repo_toplevel, run_git_capture, run_git_silent}; - -use chrono::Utc; -use mergify_core::{CliError, HttpClient}; -use serde_json::Value; - -use crate::approvals::{self, RebaseDecision}; -use crate::change_type::{self, ChangeType}; -use crate::changes::Action; -use crate::commands::sync as sync_cmd; -use crate::comment_upsert; -use crate::local_commits; -use crate::native_stack; -use crate::notes_push::{self, NotesLease, PushEntry}; -use crate::plan::{self, PlannedChange, PlannedChanges, PlannerOpts}; -use crate::pr_upsert::{self, PrUpsertInput, StaleBase}; -use crate::progress::{Mark, Progress}; -use crate::rebase_log; -use crate::remote_changes; -use crate::replay; -use crate::revision_history::RevisionHistoryComment; -use crate::revision_note; -use crate::stack_comment::StackEntry; -use crate::stack_context; -use crate::trunk; - -/// Inputs for [`run`]. Mirrors the Python `stack_push` kwargs -/// plus the bits Python pulls from the click context (server, -/// token, trunk). -#[allow( - clippy::struct_excessive_bools, - reason = "mirrors the Python CLI's flag surface 1:1" -)] -#[derive(Clone)] -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub client: &'a HttpClient, - pub mergify_binary: &'a Path, - /// Base URL (e.g. `https://api.github.com`) used for - /// revision-history compare URLs. - pub github_server: &'a str, - /// `(remote, branch)` of the trunk. - pub trunk: (&'a str, &'a str), - /// GitHub login of the author whose PRs the stack belongs - /// to. Used both for the author filter in `remote_changes` - /// and for `branch_prefix` fallback resolution. - pub author: &'a str, - /// PR branch prefix (typically `stack/<user>`); empty - /// string means "use the dest branch name directly" (the - /// Python `branch_prefix or dest_branch` fallback). - pub branch_prefix: &'a str, - pub user: &'a str, - pub repo: &'a str, - pub skip_rebase: bool, - pub force_rebase: bool, - pub next_only: bool, - pub dry_run: bool, - pub create_as_draft: bool, - pub keep_pull_request_title_and_body: bool, - pub only_update_existing_pulls: bool, - pub revision_history: bool, - pub no_verify: bool, - /// Register the pushed stack with GitHub's native Stacks API. On - /// by default; off via `--no-github-native` or git config - /// `mergify-cli.stack-github-native=false`, in which case this - /// flow issues no stacks request at all and behaves exactly as it - /// did before the feature existed. See [`crate::native_stack`]. - pub github_native: bool, -} - -/// Outcome of [`run`]. `DryRun` carries the plan + the rebase -/// decision and the buffered `log_lines` the CLI's dry-run path -/// prints. `Pushed` carries the final plan + rebase decision; the -/// real-push path streams its per-PR results live (see -/// [`crate::progress`]) rather than buffering them, so there is -/// nothing further to return. -#[derive(Debug)] -pub enum Outcome { - DryRun { - planned: PlannedChanges, - rebase: RebaseDecision, - commits_behind: u32, - log_lines: Vec<String>, - }, - Pushed { - planned: PlannedChanges, - rebase: RebaseDecision, - }, -} - -/// Execute the orchestrator. -/// -/// Returns `Ok` on a clean run (including dry-run). Errors -/// propagate as `CliError` — the binary's top-level error -/// handler maps them to exit codes. -#[allow( - clippy::too_many_lines, - reason = "single end-to-end flow that's clearer inline than split" -)] -pub async fn run(opts: &Options<'_>) -> Result<Outcome, CliError> { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let dest_branch = trunk::git_get_branch_name(Some(&repo_dir))?; - - stack_context::check_local_branch(&dest_branch, opts.branch_prefix)?; - - let (remote, base_branch) = opts.trunk; - if base_branch == dest_branch { - return Err(stack_context::targets_itself_error( - Some(&repo_dir), - &dest_branch, - remote, - base_branch, - )); - } - - let stack_prefix = if opts.branch_prefix.is_empty() { - dest_branch.clone() - } else { - format!("{prefix}/{dest_branch}", prefix = opts.branch_prefix) - }; - - // The pre-flight — trunk + notes fetch, the PR lookup, the rebase - // decision — is several network round-trips with nothing to show, - // the stretch that used to look frozen. Drive one spinner row - // through it, resolving to a kept summary line so it never blinks - // out, then seal so the live block (or the dry-run plan) starts - // fresh below. - let mut prog = Progress::new(); - let pf = prog.add("fetching trunk"); - - // The trunk + notes fetch is synchronous git; run it on a - // blocking thread so the spinner keeps ticking smoothly across it. - let notes_lease = { - let repo = repo_dir.clone(); - let remote = remote.to_string(); - let base = base_branch.to_string(); - prog.run( - pf, - "fetching trunk", - spawn_git_blocking("git fetch", move || -> Result<NotesLease, CliError> { - run_git_silent(Some(&repo), &["fetch", &remote, &base])?; - notes_push::fetch_notes_ref(Some(&repo), &remote) - }), - ) - .await? - }; - - let trunk_ref = format!("{remote}/{base_branch}"); - let base_commit_sha = compute_base_commit_sha(&repo_dir, &trunk_ref, &dest_branch)?; - - // Read before the discovery: a merged PR whose branch was deleted - // is only found under the branch name a local commit implies. - let local = local_commits::read(&repo_dir, &base_commit_sha, "HEAD")?; - - // `get_remote_changes` looks each stack branch up sequentially. - // Publish how far it is into a shared cell (`done << 32 | total`, - // one atomic so the pair is never torn); the spinner's tick loop - // reads it each frame so the label tracks the lookups while the - // glyph keeps spinning smoothly. - let reading = Arc::new(AtomicU64::new(0)); - let reading_writer = Arc::clone(&reading); - let fetch_pulls = remote_changes::get_remote_changes_reporting( - opts.client, - opts.user, - opts.repo, - &stack_prefix, - Some(opts.author), - &local, - move |done, total| { - let packed = - (u64::try_from(done).unwrap_or(0) << 32) | u64::try_from(total).unwrap_or(0); - reading_writer.store(packed, Ordering::Relaxed); - }, - ); - let (owner, repo_name) = (opts.user, opts.repo); - let remote_changes_data = prog - .run_reporting(pf, "reading pull requests", fetch_pulls, move || { - let packed = reading.load(Ordering::Relaxed); - let (done, total) = (packed >> 32, packed & u64::from(u32::MAX)); - (total != 0) - .then(|| format!("reading {owner}/{repo_name} pull requests ({done}/{total})")) - }) - .await?; - - let planner_opts = PlannerOpts { - stack_prefix: &stack_prefix, - base_branch, - only_update_existing_pulls: opts.only_update_existing_pulls, - next_only: opts.next_only, - }; - let mut planned = plan::plan(&local, remote_changes_data.clone(), planner_opts)?; - - let rebase_decision = prog - .run( - pf, - "checking rebase", - approvals::decide_rebase( - opts.client, - opts.user, - opts.repo, - &PlannedAsChanges(&planned).into(), - opts.skip_rebase, - opts.force_rebase, - ), - ) - .await?; - // Resolve the pre-flight spinner to a kept summary line (not - // erased — no blank gap) and seal it so the rebase's own git - // output, and the live blocks below, start fresh underneath. - let pre_flight_summary = format!("read {n} pull request(s)", n = remote_changes_data.len()); - prog.resolve(pf, Mark::Done, Some(&pre_flight_summary)); - prog.seal(); - - if opts.dry_run { - let mut log_lines: Vec<String> = vec!["Stacked pull request plan:".into()]; - let commits_behind = git_count_behind(&repo_dir, &trunk_ref)?; - log_lines.extend(rebase_log::rebase_dry_run( - &dest_branch, - remote, - base_branch, - commits_behind, - &rebase_decision, - )); - if rebase_decision.should_rebase && commits_behind > 0 { - plan::promote_skip_up_to_date_to_update(&mut planned); - } - push_plan_preview(&mut log_lines, &planned, opts.create_as_draft); - log_lines.push("Finished (dry-run mode).".into()); - return Ok(Outcome::DryRun { - planned, - rebase: rebase_decision, - commits_behind, - log_lines, - }); - } - - // Real-push path. `prog` (created above for the pre-flight) now - // drives the live per-PR rows. Live streaming replaces the old - // buffered plan-then-result transcript so a slow link never looks - // frozen and PRs aren't listed twice; off a TTY it degrades to - // one plain line per step (see [`crate::progress`]). - if rebase_decision.should_rebase { - let sync_opts = sync_cmd::Options { - repo_dir: Some(&repo_dir), - client: opts.client, - user: opts.user, - repo: opts.repo, - author: opts.author, - branch_prefix: opts.branch_prefix, - trunk: opts.trunk, - dry_run: false, - mergify_binary: opts.mergify_binary, - quiet: true, - // The pre-flight already fetched the trunk and the remote - // PRs; hand them through so the rebase doesn't repeat the - // per-branch PR lookups. - prefetched_remote_changes: Some(remote_changes_data.clone()), - skip_trunk_fetch: true, - }; - // Drive the rebase under a spinner — it re-reads the remote - // PRs, fetches trunk, and rebases, which used to be a silent - // stall. `quiet` captures git's output so it can't corrupt - // the in-place redraw. - let ridx = prog.add("rebasing"); - let outcome = prog - .run( - ridx, - format!("rebasing onto `{remote}/{base_branch}`"), - sync_cmd::run(&sync_opts), - ) - .await?; - let dropped = match outcome { - sync_cmd::Outcome::Synced { dropped_count, .. } => dropped_count, - sync_cmd::Outcome::DryRun(_) => 0, - }; - prog.resolve( - ridx, - Mark::Done, - Some(&rebase_log::rebase_performed( - &dest_branch, - remote, - base_branch, - dropped, - &rebase_decision, - )), - ); - - // Rebase changed local SHAs — recompute base + re-plan. - let new_base = compute_base_commit_sha(&repo_dir, &trunk_ref, &dest_branch)?; - let local2 = local_commits::read(&repo_dir, &new_base, "HEAD")?; - planned = plan::plan(&local2, remote_changes_data, planner_opts)?; - } else if let Some(line) = rebase_log::rebase_skipped(&dest_branch, &rebase_decision) { - prog.add_resolved(Mark::Noop, line); - } - - // Publishing spinner — covers change-type detection, base - // neutralization, and the push, so there's no silent gap between - // the rebase note and "pushed". Only when something is created or - // updated; a fully up-to-date stack has none of these. - let publishing = planned - .locals - .iter() - .any(|p| matches!(p.change.action, Action::Create | Action::Update)) - .then(|| prog.add("preparing")); - - // Pre-push change-type detection for the revision-history comment, - // before `push_branches` overwrites the remote refs. The old-PR- - // head fetch is network git, so run it on a blocking thread under - // the spinner. A failure only downgrades change types to - // 'unknown', so defer the warning past the live block rather than - // corrupt it with a mid-block print. - let mut change_types: std::collections::HashMap<String, ChangeType> = - std::collections::HashMap::new(); - let mut deferred_notes: Vec<String> = Vec::new(); - let mut revision_histories: std::collections::HashMap<String, (u64, RevisionHistoryComment)> = - std::collections::HashMap::new(); - if opts.revision_history { - let updated_pr_numbers: Vec<u64> = planned - .locals - .iter() - .filter(|p| matches!(p.change.action, Action::Update)) - .filter_map(|p| pull_number(p.change.pull.as_ref())) - .collect(); - if !updated_pr_numbers.is_empty() { - let repo = repo_dir.clone(); - let remote = remote.to_string(); - let numbers = updated_pr_numbers; - let fetch = spawn_git_blocking("change-type fetch", move || { - change_type::fetch_old_pr_heads(Some(&repo), &remote, &numbers) - }); - let fetched = prog - .run_optional(publishing, "analyzing changes", fetch) - .await; - if let Err(e) = fetched { - deferred_notes.push(format!( - "Could not fetch old PR heads; revision-history \ - change types will fall back to 'unknown': {e}", - )); - } - } - for entry in &planned.locals { - if !matches!(entry.change.action, Action::Update) { - continue; - } - let Some(pull) = entry.change.pull.as_ref() else { - continue; - }; - let Some(old_sha) = pull.pointer("/head/sha").and_then(Value::as_str) else { - continue; - }; - let ct = - change_type::detect_change_type(Some(&repo_dir), old_sha, &entry.change.commit_sha); - change_types.insert(entry.change.change_id.clone(), ct); - } - - // Build each Update's full revision history BEFORE the push: - // the history is written as a git note on the new head commit - // so the atomic push below carries it with the branches. The - // Mergify engine copies that note onto the merge commit at - // merge time, so it must be on the remote before the PR can - // merge — writing it after the push would leave a window. - let now = Utc::now(); - for p in &planned.locals { - if !matches!(p.change.action, Action::Update) { - continue; - } - let Some(pull) = p.change.pull.as_ref() else { - continue; - }; - let Some(number) = pull.get("number").and_then(Value::as_u64) else { - continue; - }; - let Some(old_sha) = pull.pointer("/head/sha").and_then(Value::as_str) else { - continue; - }; - - // A marker-carrying note on the local commit is a history - // note left by a previous push attempt that failed before - // the branch landed. If it records exactly this old→new - // transition, reuse it verbatim — rebuilding from the old - // head's note would lose the reason the user attached - // before the failed attempt — and skip the replay/change- - // type work below entirely: the recovered entry already - // carries its own replay_sha, and replay uploads objects - // to GitHub, so it must not run pointlessly. - if crate::revision_history::contains_marker(&p.change.note) - && let Some(history) = revision_note::recover_pending( - &p.change.note, - opts.github_server, - opts.user, - opts.repo, - old_sha, - &p.change.commit_sha, - ) - { - revision_note::write_note( - Some(&repo_dir), - &p.change.commit_sha, - &revision_note::render(&history, number), - )?; - revision_histories.insert(p.change.change_id.clone(), (number, history)); - continue; - } - - let ct = change_types - .get(&p.change.change_id) - .copied() - .unwrap_or(ChangeType::Unknown); - - // Replay only when the change is content (not rebase or - // unknown) — for rebase/unknown the revision-history - // table renders without a compare URL anyway. - let replay_sha = if matches!(ct, ChangeType::Content) { - replay::replay_for_revision( - opts.client, - Some(&repo_dir), - opts.user, - opts.repo, - old_sha, - &p.change.commit_sha, - ) - .await - } else { - None - }; - - // A marker-carrying note on the local commit that didn't - // match the recovery check above is stale machine history - // (another machine pushed meanwhile, or a different - // amend followed the failed attempt) — not a user reason. - let reason = if crate::revision_history::contains_marker(&p.change.note) { - "" - } else { - p.change.note.as_str() - }; - - let history = match revision_note::load_or_seed( - opts.client, - Some(&repo_dir), - opts.github_server, - opts.user, - opts.repo, - number, - old_sha, - ) - .await - { - Ok(Some(mut h)) => { - h.append( - old_sha, - &p.change.commit_sha, - ct, - now, - reason, - replay_sha.as_deref(), - ); - h - } - Ok(None) => RevisionHistoryComment::create_initial( - opts.github_server, - opts.user, - opts.repo, - old_sha, - &p.change.commit_sha, - ct, - now, - reason, - replay_sha.as_deref(), - ), - Err(e) => { - // A transient failure (e.g. rate limit) fetching - // the migration-seed PR comment must not fall - // back to `create_initial`: that would write a - // fresh 2-entry history and, worse, PATCH over - // the PR comment that was the only remaining - // seed — permanently destroying history a retry - // could otherwise have recovered. Leave this PR's - // history untouched this push (no note write, no - // comment update): it converges on the next push - // once the GET succeeds, since neither the old - // head's note nor the comment changed meanwhile. - deferred_notes.push(format!( - "Could not load previous revision history for #{number}; \ - leaving it untouched this push, will retry next push: {e}", - )); - continue; - } - }; - - revision_note::write_note( - Some(&repo_dir), - &p.change.commit_sha, - &revision_note::render(&history, number), - )?; - revision_histories.insert(p.change.change_id.clone(), (number, history)); - } - } - - // Step 0 of the native-stack fence — but only for the pushes that - // need one. A live registration blocks exactly one thing: a `PATCH - // /pulls/{n}` whose body carries `base`. So the fence is needed - // when, and only when, this push - // - // * retargets a PR — either through `neutralize_stale_bases` - // below or through the upsert itself, which sends `base` only - // when it moves (see `pr_upsert::base_if_changed`); or - // * tears down an orphan branch, which closes every PR still - // based on it. That is survivable only because the survivors - // were retargeted first — the very PATCH the lock blocks — and - // it would anyway leave the dropped PR in the stack, closed. - // - // A push that merely refreshes commits does neither: its PATCHes - // carry no `base`, and the force-push leaves membership intact. It - // runs over the registered stack untouched. See - // [`crate::native_stack`] for the measurements. - // - // `registered_stack` is read from the PR payloads the pre-flight - // already fetched — GitHub puts the `stack` object on them — so - // discovering the current membership costs no extra request. - let registered_stack = opts - .github_native - .then(|| { - native_stack::registered_number( - planned.locals.iter().filter_map(|p| p.change.pull.as_ref()), - ) - }) - .flatten(); - let retargets_a_pull = planned - .locals - .iter() - .filter(|p| matches!(p.change.action, Action::Update)) - .any(|p| { - p.change.pull.as_ref().is_some_and(|pull| { - pull.pointer("/base/ref").and_then(Value::as_str) != Some(p.base_branch.as_str()) - }) - }); - let needs_fence = retargets_a_pull || !planned.orphans.is_empty(); - let dissolved = if let Some(number) = registered_stack - && needs_fence - { - // Fatal on failure: the whole point is to not proceed. Unlike - // the registration at the end, this one is load-bearing. - let unstacking = native_stack::unstack(opts.client, opts.user, opts.repo, number); - prog.run_optional(publishing, "unstacking on GitHub", unstacking) - .await?; - true - } else { - false - }; - - // Before the force-push, repoint any Update PR whose base is - // moving onto the trunk. A reorder can make a head branch - // briefly an ancestor of its stale base once the atomic push - // lands, which GitHub auto-closes as "merged" (see - // `pr_upsert::neutralize_stale_bases` docs for the full - // rationale). - let stale_bases: Vec<StaleBase<'_>> = planned - .locals - .iter() - .filter(|p| matches!(p.change.action, Action::Update)) - .filter_map(|p| { - let pull = p.change.pull.as_ref()?; - let number = pull.get("number").and_then(Value::as_u64)?; - let current_base_ref = pull.pointer("/base/ref").and_then(Value::as_str)?; - Some(StaleBase { - pull_number: number, - current_base_ref, - new_base_ref: &p.base_branch, - }) - }) - .collect(); - let neutralize = pr_upsert::neutralize_stale_bases( - opts.client, - opts.user, - opts.repo, - &stale_bases, - base_branch, - ); - prog.run_optional(publishing, "preparing branches", neutralize) - .await?; - - // The actual push. - let push_entries: Vec<PushEntry> = planned - .locals - .iter() - .filter(|p| matches!(p.change.action, Action::Create | Action::Update)) - .map(|p| { - let pull_head_sha = if matches!(p.change.action, Action::Update) { - p.change - .pull - .as_ref() - .and_then(|v| v.pointer("/head/sha")) - .and_then(Value::as_str) - .map(str::to_owned) - } else { - None - }; - PushEntry { - commit_sha: p.change.commit_sha.clone(), - dest_branch: p.dest_branch.clone(), - pull_head_sha, - } - }) - .collect(); - let pushed = push_entries.len(); - { - // Synchronous git push on a blocking thread so the spinner - // keeps ticking across the network round-trip. - let repo = repo_dir.clone(); - let remote_owned = remote.to_string(); - let no_verify = opts.no_verify; - let push_notes_lease = notes_lease.clone(); - let push = spawn_git_blocking("git push", move || { - notes_push::push_branches( - Some(&repo), - &remote_owned, - &push_entries, - no_verify, - &push_notes_lease, - ) - }); - prog.run_optional(publishing, format!("pushing {pushed} branch(es)"), push) - .await?; - } - if pushed > 0 && matches!(notes_lease, NotesLease::Unknown) { - deferred_notes.push( - "Could not determine the remote notes state; stack notes were not \ - pushed and will catch up on the next push." - .to_string(), - ); - } - if let Some(idx) = publishing { - prog.resolve( - idx, - Mark::Done, - Some(&format!("pushed {pushed} branch(es)")), - ); - } - - // One live row per planned change, in stack order. Create/Update - // start `queued` and spin while their upsert is in flight; the - // skip variants resolve immediately. `row_idx[i]` maps each local - // back to its row for the upsert loop below. - let mut row_idx: Vec<Option<usize>> = Vec::with_capacity(planned.locals.len()); - for p in &planned.locals { - let url = pull_url(p.change.pull.as_ref()); - let title = p.change.title.clone(); - match p.change.action { - Action::Create | Action::Update => { - // Dim SHA transition shown beside the status: the old - // PR-head short SHA → the new commit short SHA for an - // update, just the new short SHA for a create. - let new7 = short_sha(&p.change.commit_sha); - let detail = match p.change.action { - Action::Update => pull_head_short_sha(p.change.pull.as_ref()) - .map(|old7| format!("{old7}→{new7}")), - _ => Some(new7), - }; - row_idx.push(Some(prog.add_pr(title, "queued", detail, None))); - } - Action::SkipMerged => { - prog.add_resolved_pr(title, Mark::Merged, "merged", url); - row_idx.push(None); - } - Action::SkipUpToDate => { - prog.add_resolved_pr(title, Mark::Noop, "up-to-date", url); - row_idx.push(None); - } - Action::SkipCreate | Action::SkipNextOnly => { - prog.add_resolved_pr(title, Mark::Noop, "skipped", url); - row_idx.push(None); - } - } - } - - // Sequential per-PR upsert so each Depends-On has access to the - // predecessor's freshly-known PR number — and fine for typical - // stack sizes (see the module doc on why this isn't parallelised). - // - // With github-native registration on, the marker is left out of - // the rendered bodies: a registered stack IS the ordering, held by - // GitHub, and - // a second copy of it in prose is one the reader has to reconcile. - // Optimistically, though — step 12 is where the registration is - // actually attempted, and it is allowed to quietly not happen. So - // every marker skipped here is remembered, and written back below - // if the stack ends the push unregistered. - let mut suppressed_markers: Vec<SuppressedMarker> = Vec::new(); - let mut last_pull_number: Option<u64> = None; - for (i, entry) in planned.locals.iter_mut().enumerate() { - let action = entry.change.action; - if !matches!(action, Action::Create | Action::Update) { - // Skip-* still carries an existing pull number - // forward as a potential predecessor for downstream - // Depends-On chaining. Mirrors Python's - // `_build_change_tasks` carry-forward semantics. - if let Some(n) = pull_number(entry.change.pull.as_ref()) { - last_pull_number = Some(n); - } - continue; - } - let idx = row_idx[i].expect("create/update rows are always added"); - - // `Some` = a marker this push is deliberately not writing. - let suppressed = last_pull_number.filter(|_| opts.github_native); - let input = PrUpsertInput { - action, - title: &entry.change.title, - message: &entry.change.message, - dest_branch: &entry.dest_branch, - base_branch: &entry.base_branch, - pull: entry.change.pull.as_ref(), - depends_on_number: if opts.github_native { - None - } else { - last_pull_number - }, - create_as_draft: opts.create_as_draft, - keep_pull_request_title_and_body: opts.keep_pull_request_title_and_body, - }; - // Captured before the upsert consumes `input`, and owned - // because the loop goes on mutating `planned.locals`. - let restore_source = suppressed.map(|_| pr_upsert::description_source(&input).to_string()); - let active = if matches!(action, Action::Create) { - "creating" - } else { - "updating" - }; - let pull = prog - .run( - idx, - active, - pr_upsert::create_or_update_pr(opts.client, opts.user, opts.repo, input), - ) - .await?; - let number = pull.get("number").and_then(Value::as_u64); - if let Some(n) = number { - last_pull_number = Some(n); - if let (Some(depends_on_number), Some(source)) = (suppressed, restore_source) { - suppressed_markers.push(SuppressedMarker { - pull_number: n, - depends_on_number, - source, - }); - } - } - let url = pull - .get("html_url") - .and_then(Value::as_str) - .map(str::to_string); - prog.set_url(idx, url); - let done_word = if matches!(action, Action::Create) { - "created" - } else { - "updated" - }; - prog.resolve(idx, Mark::Done, Some(done_word)); - entry.change.pull = Some(pull); - } - - // Revision-history comments — rendered from the histories - // prepared (and written as git notes) before the push. - if !revision_histories.is_empty() { - let ridx = prog.add("queued"); - prog.run(ridx, "updating revision history", async { - for p in &planned.locals { - let Some((number, history)) = revision_histories.get(&p.change.change_id) else { - continue; - }; - comment_upsert::upsert_revision_history_comment( - opts.client, - opts.user, - opts.repo, - *number, - &history.body(*number), - ) - .await?; - } - Ok::<(), CliError>(()) - }) - .await?; - prog.resolve(ridx, Mark::Done, Some("revision history updated")); - } - - // Orphan-branch teardown — last so we don't yank the rug out - // from anything earlier in the flow that might still reference - // an orphan PR. - for orphan in &planned.orphans { - let Some(head_ref) = orphan.pointer("/head/ref").and_then(Value::as_str) else { - continue; - }; - let title = orphan - .get("title") - .and_then(Value::as_str) - .unwrap_or("<unknown>") - .to_string(); - let url = orphan - .get("html_url") - .and_then(Value::as_str) - .map(str::to_string); - let oidx = prog.add_pr(title, "queued", None, None); - prog.set_url(oidx, url); - prog.run( - oidx, - "deleting", - pr_upsert::delete_orphan_branch(opts.client, opts.user, opts.repo, head_ref), - ) - .await?; - prog.resolve(oidx, Mark::Noop, Some("deleted")); - } - - // Step 12 — make GitHub's registration describe the stack this - // push produced. Last, after the orphan teardown, so the members - // are the ones that survived it: retargeted during the upsert loop, - // with the dropped changes' branches already gone. - // - // Three outcomes, cheapest first, because a re-registration is not - // free — it churns the stack number and re-emits - // `pull_request.stacked` for every member: - // - // - the registration already describes the stack → no request; - // - it is missing changes on top → one `append`, which preserves - // the stack and its members' registration; - // - anything else (a reorder, a drop, a change inserted in the - // middle, or a stack we dissolved up front) → register from - // scratch, which is only possible because nothing is registered - // at that point. - // - // The outcome is load-bearing beyond the progress line: it decides - // whether the `Depends-On:` markers the upsert left out have to be - // written back. - let mut stack_is_registered = false; - if opts.github_native { - // The stack still standing on GitHub, if any: the one we read - // before the mutations and did not dissolve. - let live_stack = if dissolved { None } else { registered_stack }; - // Bottom-to-top, open members only. Merged members are - // GitHub's to infer, and orphans aren't in `locals` at all. - let members: Vec<native_stack::Member> = planned - .locals - .iter() - .filter_map(|p| { - let pull = p.change.pull.as_ref()?; - let number = native_stack::open_pull_number(Some(pull))?; - Some(native_stack::Member { - number, - registered: live_stack.is_some_and(|n| native_stack::is_member_of(pull, n)), - }) - }) - .collect(); - let numbers: Vec<u64> = members.iter().map(|m| m.number).collect(); - - // `Some(tail)` on a live stack is an append (possibly of - // nothing); everything else falls through to a full rebuild. - let tail = live_stack.zip(native_stack::appendable_tail(&members)); - match tail { - Some((number, tail)) if tail.is_empty() => { - prog.add_resolved(Mark::Noop, format!("GitHub stack #{number} unchanged")); - stack_is_registered = true; - } - Some((number, tail)) => { - let sidx = prog.add("queued"); - let appended = prog - .run( - sidx, - "extending GitHub stack", - native_stack::append(opts.client, opts.user, opts.repo, number, &tail), - ) - .await; - if appended { - prog.resolve( - sidx, - Mark::Done, - Some(&format!("added to GitHub stack #{number}")), - ); - stack_is_registered = true; - } else { - // The append is best-effort by design; when GitHub - // won't extend the stack (someone dissolved it, the - // chain doesn't line up) rebuild it rather than - // leaving a registration that describes a stack - // that no longer exists. The mutations are done, so - // a failing unstack is now harmless — it just means - // the registration stays as it was. - let _ = native_stack::unstack(opts.client, opts.user, opts.repo, number).await; - stack_is_registered = register_stack(opts, &mut prog, sidx, &numbers).await; - } - } - None => { - let sidx = prog.add("queued"); - stack_is_registered = register_stack(opts, &mut prog, sidx, &numbers).await; - } - } - } - - // The other half of the optimism above. GitHub is not holding the - // order after all, so the only thing that describes it is the - // marker — Mergify rebuilds a stack from `Depends-On:` chains, and - // without them a mid-stack pull request has nothing keeping it - // behind its predecessor. Put them back. - // - // Fatal on failure, unlike everything else about github-native - // registration: every other way this feature degrades leaves the - // pull requests in the state a `--no-github-native` push produces, - // and this one would not. A stack that is neither registered nor - // chained is the one outcome worth stopping for. - if !stack_is_registered && !suppressed_markers.is_empty() { - let ridx = prog.add("queued"); - prog.run(ridx, "restoring dependencies", async { - for marker in &suppressed_markers { - pr_upsert::restore_depends_on( - opts.client, - opts.user, - opts.repo, - marker.pull_number, - &marker.source, - marker.depends_on_number, - ) - .await?; - } - Ok::<(), CliError>(()) - }) - .await?; - prog.resolve(ridx, Mark::Done, Some("dependencies restored")); - deferred_notes.push( - "GitHub is not holding this stack's order, so the pull requests keep their \ - `Depends-On:` headers and Mergify orders them." - .to_string(), - ); - } - - // The stack comment, settled on the same bet as the `Depends-On:` - // headers above and for the same reason: a registered stack has - // GitHub listing its own members on the pull request page, so our - // sticky table is the redundant copy and this push does not write - // it. What it does instead is take down the ones earlier pushes - // left, because nothing refreshes them any more — a table frozen - // at the stack's shape as of the last pre-native push, sitting - // under the live list, is worse than no table. A push that did not - // register has nothing else describing the stack's shape, so it - // keeps the comment exactly as before. - // - // Only the fallback upsert is gated on the stack having more than - // one pull request. That gate is about cost — a one-change stack - // never got a comment, so looking for one is a GET per push that - // can only come back empty — and it is sound there because - // `update_stack_comment_for_pull` is the thing that would have - // written the comment in the first place. - // - // Applying it to the removal would take the PR's own guarantee - // away in the one case that needs it. A registered stack whose - // members merge down to a single open pull request keeps its - // registration (`appendable_tail` reports nothing to add, so the - // stack stands), so it arrives here registered with one live - // member — carrying the comment it was given while it still had - // several, now frozen under GitHub's live list. That is exactly - // the stale table this step exists to take down. It costs nothing - // in the steady state: GitHub rejects a stack below - // `native_stack::MIN_STACK_SIZE`, so a genuinely one-change stack - // is never registered and never reaches this branch at all. - // - // The case still not covered is a stack that shrank *and* lost its - // registration (GitHub dissolves a 2-member stack that drops to - // one). It leaves the same stale comment, and finding it would - // mean a GET on every single-change push for ever — the cost the - // gate above exists to avoid — to clean an artifact only stacks - // pushed before native registration shipped can have. Left as it - // was on `main`, where this gap already existed. - let entries: Vec<StackEntry> = planned - .locals - .iter() - .filter_map(stack_entry_from_planned) - .collect(); - let total_pulls = entries.len(); - if stack_is_registered || total_pulls > 1 { - // Merged pull requests are skipped, as the upsert always - // skipped them: a landed pull request is a closed record of a - // review, not part of the shape this push is describing, and - // that holds for taking a comment down as much as for - // rewriting one. - let live: Vec<u64> = planned - .locals - .iter() - .filter_map(|p| { - let pull = p.change.pull.as_ref()?; - if pull.get("merged_at").is_some_and(|v| !v.is_null()) { - return None; - } - pull.get("number").and_then(Value::as_u64) - }) - .collect(); - - if stack_is_registered { - let mut removed = 0usize; - let mut failure: Option<CliError> = None; - for number in &live { - match comment_upsert::remove_stack_comment_for_pull( - opts.client, - opts.user, - opts.repo, - *number, - ) - .await - { - Ok(true) => removed += 1, - Ok(false) => {} - // Unlike the `Depends-On:` restore above, this - // changes nothing about how the stack merges — it - // clears a surface GitHub now draws itself. Worth - // one more attempt on the next push; never worth - // failing a push that has already done everything. - Err(e) => failure = failure.or(Some(e)), - } - } - // Only ever reported when there was something to report: - // in the steady state every stack comment is long gone and - // a row saying so on every push is a tombstone. - if removed > 0 { - let plural = if removed == 1 { "" } else { "s" }; - prog.add_resolved( - Mark::Noop, - format!("removed {removed} stack comment{plural}"), - ); - } - if let Some(e) = failure { - deferred_notes.push(format!( - "Could not remove the stack comment GitHub's stack UI replaces; \ - the next push tries again. ({e})" - )); - } - } else { - let cidx = prog.add("queued"); - prog.run(cidx, "updating stack comments", async { - for number in &live { - comment_upsert::update_stack_comment_for_pull( - opts.client, - opts.user, - opts.repo, - *number, - &entries, - &dest_branch, - total_pulls, - ) - .await?; - } - Ok::<(), CliError>(()) - }) - .await?; - prog.resolve(cidx, Mark::Done, Some("stack comments updated")); - } - } - - // Warnings stashed during the live block (a mid-block print would - // corrupt the in-place redraw) surface now, after the last row. - for note in deferred_notes { - prog.note(note); - } - // Real-push streams live; the buffered transcript is unused. - // When nothing reached the remote — no branch pushed, no orphan - // deleted — the stack already matched GitHub. A preceding rebase - // only rewrote local refs, so it still reports as up to date. - let closing = if pushed == 0 && planned.orphans.is_empty() { - "Already up to date." - } else { - "Finished." - }; - let _ = prog.finish(closing); - - Ok(Outcome::Pushed { - planned, - rebase: rebase_decision, - }) -} - -/// A `Depends-On:` marker the upsert left out of a pull request body -/// because github-native registration was going to hand the ordering -/// to GitHub. Held until the registration's outcome is known — see -/// [`pr_upsert::restore_depends_on`]. -struct SuppressedMarker { - /// The pull request whose body is missing its marker. - pull_number: u64, - /// The predecessor the marker would have pointed at. - depends_on_number: u64, - /// The text the body was rendered from, per - /// [`pr_upsert::description_source`] — re-rendering from anything - /// else would not reproduce the body that is on the PR now. - source: String, -} - -/// Register `numbers` (bottom-to-top) as a native stack and report the -/// outcome on progress row `idx`. Returns whether GitHub took it. -/// -/// Never fails the push: a repo without the Stacks API, a chain with a -/// hole in it, or a stack below GitHub's 2-PR floor all just leave the -/// PRs unregistered, which is the state the flag-off flow produces -/// anyway. See [`crate::native_stack::register`]. -async fn register_stack( - opts: &Options<'_>, - prog: &mut Progress, - idx: usize, - numbers: &[u64], -) -> bool { - let registered = prog - .run( - idx, - "registering GitHub stack", - native_stack::register(opts.client, opts.user, opts.repo, numbers), - ) - .await; - if let Some(number) = registered { - prog.resolve( - idx, - Mark::Done, - Some(&format!("registered as GitHub stack #{number}")), - ); - true - } else { - prog.resolve(idx, Mark::Noop, Some("not registered on GitHub")); - false - } -} - -/// Append the "what will happen" preview lines (locals first, then -/// orphans), in the would-be wording. Mirrors Python's -/// `changes.display_plan`. -fn push_plan_preview(log: &mut Vec<String>, planned: &PlannedChanges, create_as_draft: bool) { - for p in &planned.locals { - log.push(crate::changes::format_local_change_log( - &p.change, - &p.dest_branch, - true, - create_as_draft, - )); - } - for orphan in &planned.orphans { - log.push(crate::changes::format_orphan_change_log(orphan, true)); - } -} - -/// PR number from a pull payload, if present. -fn pull_number(pull: Option<&Value>) -> Option<u64> { - pull?.get("number").and_then(Value::as_u64) -} - -/// First 7 chars of a SHA — the conventional short form shown in -/// the progress detail. -fn short_sha(sha: &str) -> String { - sha.chars().take(7).collect() -} - -/// Short head SHA of a PR payload, if present. -fn pull_head_short_sha(pull: Option<&Value>) -> Option<String> { - pull? - .pointer("/head/sha") - .and_then(Value::as_str) - .map(short_sha) -} - -/// Run a synchronous git operation on a blocking thread, mapping a -/// task-join panic into a `CliError`. Collapses the -/// `spawn_blocking(...).await.map_err(...)?` boilerplate the push -/// flow repeats for each off-executor git step (`label` names the -/// step in the panic message). -async fn spawn_git_blocking<T, F>(label: &str, f: F) -> Result<T, CliError> -where - F: FnOnce() -> Result<T, CliError> + Send + 'static, - T: Send + 'static, -{ - tokio::task::spawn_blocking(f) - .await - .map_err(|e| CliError::Generic(format!("{label} task panicked: {e}")))? -} - -/// PR `html_url` from a pull payload, if present. -fn pull_url(pull: Option<&Value>) -> Option<String> { - pull? - .get("html_url") - .and_then(Value::as_str) - .map(str::to_string) -} - -/// Build a `StackEntry` from a `PlannedChange` — pulls every -/// field the comment renderer needs out of the (possibly newly- -/// upserted) PR payload. Returns `None` for entries without a -/// pull (i.e. Create whose POST hasn't run, or a Skip-* without -/// any pull). -fn stack_entry_from_planned(p: &PlannedChange) -> Option<StackEntry> { - let pull = p.change.pull.as_ref()?; - let number = pull.get("number").and_then(Value::as_u64)?; - let head_sha = pull - .pointer("/head/sha") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - let title = pull - .get("title") - .and_then(Value::as_str) - .unwrap_or(&p.change.title) - .to_string(); - let html_url = pull - .get("html_url") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); - Some(StackEntry { - number, - change_id: p.change.change_id.clone(), - head_sha, - base_branch: p.base_branch.clone(), - dest_branch: p.dest_branch.clone(), - title, - html_url, - }) -} - -/// `decide_rebase` needs a [`crate::changes::Changes`] — bridge -/// from `PlannedChanges` by stripping the planner-added fields. -struct PlannedAsChanges<'a>(&'a PlannedChanges); - -impl<'a> From<PlannedAsChanges<'a>> for crate::changes::Changes { - fn from(p: PlannedAsChanges<'a>) -> Self { - crate::changes::Changes { - locals: p.0.locals.iter().map(|e| e.change.clone()).collect(), - orphans: p.0.orphans.clone(), - } - } -} - -fn git_count_behind(repo_dir: &Path, trunk_ref: &str) -> Result<u32, CliError> { - let out = run_git_capture( - Some(repo_dir), - &["rev-list", "--count", &format!("HEAD..{trunk_ref}")], - )?; - out.parse::<u32>().map_err(|e| { - CliError::Generic(format!( - "`git rev-list --count` returned non-integer {out:?}: {e}" - )) - }) -} diff --git a/crates/mergify-stack/src/commands/reorder.rs b/crates/mergify-stack/src/commands/reorder.rs deleted file mode 100644 index 5b998572..00000000 --- a/crates/mergify-stack/src/commands/reorder.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! `mergify stack reorder <COMMIT>... [--dry-run]` — rebase the -//! stack with the given commit order. -//! -//! Port of `mergify_cli/stack/reorder.py::stack_reorder`. The -//! `<COMMIT>...` arguments are SHA prefixes (or Change-Id -//! prefixes) — one per stack commit, in the desired new order. -//! Validation rejects duplicate prefixes, prefixes that don't -//! resolve, and length mismatches. When the requested order -//! matches the current order, we short-circuit with an "already -//! in order" outcome and don't spawn a rebase. - -use std::path::Path; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrderedCommit { - sha: String, - subject: String, - change_id: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Reorder ran to completion. `plan` is the new full-stack - /// order (tag-less, like Python's `display_plan`). - Reordered { - plan: Vec<PlanRow>, - }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { - plan: Vec<PlanRow>, - }, - /// New order matches current order — no rebase needed. - AlreadyInOrder, - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefixes: &'a [String], - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -pub fn run(opts: &Options<'_>) -> Result<Outcome, CliError> { - if opts.commit_prefixes.is_empty() { - return Err(CliError::InvalidState( - "no commit prefixes given".to_string(), - )); - } - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - if opts.commit_prefixes.len() != commits.len() { - return Err(CliError::InvalidState(format!( - "expected {} commits but got {} prefixes", - commits.len(), - opts.commit_prefixes.len() - ))); - } - - let mut resolved: Vec<OrderedCommit> = Vec::with_capacity(opts.commit_prefixes.len()); - let mut seen = std::collections::HashSet::new(); - for prefix in opts.commit_prefixes { - let matched = match_commit(prefix, &commits)?; - if !seen.insert(matched.sha.clone()) { - return Err(CliError::InvalidState(format!( - "duplicate — prefix '{prefix}' resolves to the same commit as another prefix" - ))); - } - resolved.push(matched); - } - - let current_shas: Vec<&str> = commits.iter().map(|c| c.commit_sha.as_str()).collect(); - let requested_shas: Vec<&str> = resolved.iter().map(|c| c.sha.as_str()).collect(); - if current_shas == requested_shas { - return Ok(Outcome::AlreadyInOrder); - } - - let plan: Vec<PlanRow> = resolved - .iter() - .map(|c| PlanRow { - sha: c.sha.clone(), - subject: c.subject.clone(), - change_id: c.change_id.clone(), - action: None, - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - spawn_reorder_rebase(&repo_dir, &base, opts.mergify_binary, &requested_shas)?; - Ok(Outcome::Reordered { plan }) -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<OrderedCommit, CliError> { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(OrderedCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - change_id: only.change_id.clone(), - }), - many => { - let candidates: Vec<crate::match_commit::Candidate<'_>> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn spawn_reorder_rebase( - repo_dir: &Path, - base: &str, - mergify_binary: &Path, - ordered_shas: &[&str], -) -> Result<(), CliError> { - let bin = shell_quote(&mergify_binary.to_string_lossy()); - let shas = shell_quote(&ordered_shas.join(",")); - let editor = format!("{bin} _internal rebase-todo-rewrite --action reorder --shas {shas}"); - spawn_rebase(repo_dir, base, Some(&editor), false) -} diff --git a/crates/mergify-stack/src/commands/reword.rs b/crates/mergify-stack/src/commands/reword.rs deleted file mode 100644 index 99229282..00000000 --- a/crates/mergify-stack/src/commands/reword.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! `mergify stack reword <COMMIT> [-m <msg>] [--dry-run]` — -//! change a commit's message in place. -//! -//! Port of `mergify_cli/stack/reword.py::stack_reword`. Two -//! flavors: -//! -//! - **No `-m`**: marks the target as `reword` in the rebase-todo. -//! Git stops at that commit and runs `git commit --amend`, -//! opening `$GIT_EDITOR`. Works in a TTY, hangs in agent -//! contexts. -//! - **With `-m`**: writes the message to a tempfile and injects -//! `exec git commit --amend -F <file>` right after the target -//! `pick`. The amend runs while HEAD points at the target, so -//! any `prepare-commit-msg` hook re-attaches the Change-Id. -//! The tempfile is intentionally leaked: if the rebase pauses -//! on a conflict, `git rebase --continue` needs the file to -//! complete the `exec`. The OS cleans up `/tmp` on its own. - -use std::io::Write; -use std::path::{Path, PathBuf}; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RewordedCommit { - pub sha: String, - pub subject: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Reword ran to completion. `plan` is the full stack in - /// `base..HEAD` order with a `[reword]`/`[amend]` tag on the - /// target row. - Reworded { - plan: Vec<PlanRow>, - }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { - plan: Vec<PlanRow>, - }, - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub commit_prefix: &'a str, - /// `Some` for the non-interactive `-m` path; `None` lets git - /// open `$GIT_EDITOR` via the `reword` todo verb. - pub message: Option<&'a str>, - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -/// Resolve the trunk, walk the stack, match the commit, and run -/// the scripted rebase (or short-circuit on `--dry-run` / -/// empty-stack). -pub fn run(opts: &Options<'_>) -> Result<Outcome, CliError> { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let target = match_commit(opts.commit_prefix, &commits)?; - - // No `-m` opens an editor (`reword` verb); `-m` amends the - // message non-interactively (`amend`). - let action = if opts.message.is_some() { - "amend" - } else { - "reword" - }; - let plan: Vec<PlanRow> = commits - .iter() - .map(|c| PlanRow { - sha: c.commit_sha.clone(), - subject: c.title.clone(), - change_id: c.change_id.clone(), - action: (c.commit_sha == target.sha).then(|| action.to_string()), - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - let editor = if let Some(msg) = opts.message { - // Leak intentionally so `git rebase --continue` can still - // find the file on conflict. - let msg_path = write_temp_message(msg)?; - let command = format!( - "git commit --amend -F {}", - shell_quote(&msg_path.to_string_lossy()) - ); - build_sequence_editor_exec(opts.mergify_binary, &target.sha, &command) - } else { - build_sequence_editor_reword(opts.mergify_binary, &target.sha) - }; - spawn_rebase(&repo_dir, &base, Some(&editor), false)?; - Ok(Outcome::Reworded { plan }) -} - -fn write_temp_message(message: &str) -> Result<PathBuf, CliError> { - // `into_temp_path` returns a path the caller owns; `keep` - // converts it into a regular path that persists past the - // tempfile's lifetime. Matches Python's intentional leak. - let mut tmp = tempfile::Builder::new() - .prefix("mergify_reword_msg_") - .suffix(".txt") - .tempfile() - .map_err(|e| CliError::Generic(format!("create reword tempfile: {e}")))?; - tmp.write_all(message.as_bytes()) - .map_err(|e| CliError::Generic(format!("write reword tempfile: {e}")))?; - tmp.flush() - .map_err(|e| CliError::Generic(format!("flush reword tempfile: {e}")))?; - let (_, path) = tmp - .keep() - .map_err(|e| CliError::Generic(format!("persist reword tempfile: {e}")))?; - Ok(path) -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<RewordedCommit, CliError> { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(RewordedCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - }), - many => { - let candidates: Vec<crate::match_commit::Candidate<'_>> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn build_sequence_editor_reword(binary: &Path, sha: &str) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let sha = shell_quote(sha); - format!("{bin} _internal rebase-todo-rewrite --action reword --sha {sha}") -} - -fn build_sequence_editor_exec(binary: &Path, sha: &str, command: &str) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let sha = shell_quote(sha); - let command = shell_quote(command); - format!( - "{bin} _internal rebase-todo-rewrite --action exec-after --sha {sha} --command {command}" - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn build_repo() -> (tempfile::TempDir, Vec<String>) { - let workdir = tempfile::tempdir().unwrap(); - let upstream = workdir.path().join("up.git"); - crate::test_env::isolated_git() - .args([ - "init", - "-q", - "--bare", - "-b", - "main", - upstream.to_str().unwrap(), - ]) - .status() - .unwrap(); - let local = workdir.path().join("local"); - std::fs::create_dir(&local).unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - &["remote", "add", "origin", upstream.to_str().unwrap()], - &["push", "-q", "origin", "main"], - &["remote", "set-head", "origin", "main"], - &["checkout", "-q", "-b", "feature"], - ] { - crate::test_env::isolated_git() - .arg("-C") - .arg(&local) - .args(args) - .status() - .unwrap(); - } - let mut commits = Vec::new(); - for (label, cid) in [ - ("A", "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"), - ("B", "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"), - ] { - let msg = format!("Commit {label}\n\nChange-Id: {cid}"); - crate::test_env::isolated_git() - .arg("-C") - .arg(&local) - .args(["commit", "--allow-empty", "-m", &msg]) - .status() - .unwrap(); - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(&local) - .args(["rev-parse", "HEAD"]) - .output() - .unwrap(); - commits.push(String::from_utf8(out.stdout).unwrap().trim().to_string()); - } - (workdir, commits) - } - - #[test] - fn dry_run_returns_target() { - let (work, commits) = build_repo(); - let local = work.path().join("local"); - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefix: &commits[1][..12], - message: None, - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - match outcome { - Outcome::DryRun { plan } => { - // Full stack (A, B); B is the reworded target. - assert_eq!(plan.len(), 2); - assert_eq!(plan[1].sha, commits[1]); - assert_eq!(plan[1].subject, "Commit B"); - assert_eq!(plan[1].action.as_deref(), Some("reword")); - assert_eq!(plan[0].action, None); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn dry_run_with_message_signals_amend() { - let (work, commits) = build_repo(); - let local = work.path().join("local"); - let outcome = run(&Options { - repo_dir: Some(&local), - commit_prefix: &commits[1][..12], - message: Some("new subject"), - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap(); - match outcome { - Outcome::DryRun { plan } => { - // `-m` tags the target as `amend` rather than `reword`. - assert_eq!(plan[1].action.as_deref(), Some("amend")); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn unknown_prefix_returns_stack_not_found() { - let (work, _) = build_repo(); - let local = work.path().join("local"); - let err = run(&Options { - repo_dir: Some(&local), - commit_prefix: "deadbeef1234", - message: None, - dry_run: true, - mergify_binary: Path::new("does-not-matter"), - }) - .unwrap_err(); - match err { - CliError::StackNotFound(_) => {} - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/commands/setup.rs b/crates/mergify-stack/src/commands/setup.rs deleted file mode 100644 index 21a0bca2..00000000 --- a/crates/mergify-stack/src/commands/setup.rs +++ /dev/null @@ -1,544 +0,0 @@ -//! `mergify stack hooks` (status + `--setup`) and `mergify stack -//! setup` — install the git hooks the stack workflow depends on. -//! -//! Port of `mergify_cli/stack/setup.py`. The on-disk layout is -//! the same one Python's installer wrote, so an existing checkout -//! upgraded from a Python install is recognised without -//! re-installation: -//! -//! ```text -//! .git/hooks/<hook> # thin wrapper (sources the managed script; -//! # user may add custom logic below the marker) -//! .git/hooks/mergify-hooks/<hook>.sh # managed script (always upgradable) -//! ``` -//! -//! Wrappers carry a stable `mergify-hooks` + `<hook>.sh` substring -//! pair so the status detector can tell a Mergify wrapper from -//! the user's own hook. The legacy heuristics (commit-msg -//! `Change-Id: I${random}` and prepare-commit-msg -//! `is_amend_with_m_flag`) match the pre-sourcing-architecture -//! installs so `--force` knows to migrate them. - -use std::fs; -use std::io::Write as _; -use std::path::{Path, PathBuf}; - -use crate::git::run_git_capture; -use crate::local_commits::STACK_NOTES_REF; - -use mergify_core::CliError; - -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; - -/// One git hook this crate manages. The names match files in -/// `crates/mergify-stack/hooks/{scripts,wrappers}/`. -const HOOK_NAMES: &[&str] = &[ - "commit-msg", - "post-commit", - "pre-push", - "prepare-commit-msg", -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -pub enum WrapperStatus { - /// No wrapper present. - Missing, - /// Pre-sourcing-architecture wrapper — needs `--force` to migrate. - Legacy, - /// Our wrapper (sources `mergify-hooks/<hook>.sh`) OR the user - /// installed their own hook in this slot. Both are treated - /// the same: we don't touch them. - Installed, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct HookStatus { - pub hook_name: String, - pub wrapper_status: WrapperStatus, - pub script_installed: bool, - pub script_needs_update: bool, - pub wrapper_path: String, - pub script_path: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct HooksStatus { - pub git_hooks: Vec<HookStatus>, -} - -/// What `_install_git_hook` did to each hook this run — surfaced -/// so the binary handler can render the same `Installing hook -/// wrapper: <name>` / `Updating managed hook script: …` / `Found -/// legacy hook: <name>` lines Python printed. -#[derive(Debug, Clone, serde::Serialize)] -pub enum HookAction { - ScriptInstalled, - ScriptUpdated, - ScriptUpToDate, - WrapperInstalled, - WrapperMigrated, - WrapperLegacyNeedsForce, - WrapperAlreadyInstalled, -} - -#[derive(Debug, Clone)] -pub struct InstallLog { - pub hook_name: String, - pub actions: Vec<HookAction>, -} - -/// Outcome of an `install` run: the per-hook action log plus, for -/// each of the local `notes.displayRef` and `notes.rewriteRef` -/// configs, whether it was added this run (so the caller can echo -/// Python's `Added <key> = …` confirmation only for what changed). -#[derive(Debug, Clone)] -pub struct InstallOutcome { - pub logs: Vec<InstallLog>, - pub notes_display_ref_added: bool, - pub notes_rewrite_ref_added: bool, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub force: bool, -} - -/// Walk every managed hook and emit its current status. Mirrors -/// Python's `get_hooks_status`. -pub fn status(repo_dir: Option<&Path>) -> Result<HooksStatus, CliError> { - let hooks_dir = resolve_hooks_dir(repo_dir)?; - let managed_dir = hooks_dir.join("mergify-hooks"); - let mut git_hooks = Vec::with_capacity(HOOK_NAMES.len()); - for &hook in HOOK_NAMES { - let wrapper_path = hooks_dir.join(hook); - let script_path = managed_dir.join(format!("{hook}.sh")); - let wrapper_status = wrapper_status(&wrapper_path, hook); - let script_installed = script_path.exists(); - let script_needs_update = if script_installed { - script_differs(&script_path, hook) - } else { - true - }; - git_hooks.push(HookStatus { - hook_name: hook.to_string(), - wrapper_status, - script_installed, - script_needs_update, - wrapper_path: wrapper_path.to_string_lossy().into_owned(), - script_path: script_path.to_string_lossy().into_owned(), - }); - } - Ok(HooksStatus { git_hooks }) -} - -/// Install or upgrade every managed hook. Returns a per-hook -/// action log so callers can render the right messages. -pub fn install(opts: &Options<'_>) -> Result<InstallOutcome, CliError> { - let hooks_dir = resolve_hooks_dir(opts.repo_dir)?; - let managed_dir = hooks_dir.join("mergify-hooks"); - fs::create_dir_all(&managed_dir).map_err(|e| { - CliError::Generic(format!( - "create managed hooks dir {}: {e}", - managed_dir.display() - )) - })?; - - let mut logs = Vec::with_capacity(HOOK_NAMES.len()); - for &hook in HOOK_NAMES { - let mut actions = Vec::new(); - install_single_hook(&hooks_dir, &managed_dir, hook, opts.force, &mut actions)?; - logs.push(InstallLog { - hook_name: hook.to_string(), - actions, - }); - } - - let notes_display_ref_added = ensure_notes_display_ref(opts.repo_dir)?; - let notes_rewrite_ref_added = ensure_notes_rewrite_ref(opts.repo_dir)?; - Ok(InstallOutcome { - logs, - notes_display_ref_added, - notes_rewrite_ref_added, - }) -} - -fn install_single_hook( - hooks_dir: &Path, - managed_dir: &Path, - hook: &str, - force: bool, - actions: &mut Vec<HookAction>, -) -> Result<(), CliError> { - let script_path = managed_dir.join(format!("{hook}.sh")); - let new_script = script_resource(hook); - let wrapper_path = hooks_dir.join(hook); - let new_wrapper = wrapper_resource(hook); - - // Managed script: install or refresh when the content drifts. - if !script_path.exists() { - write_file_with_mode(&script_path, new_script, 0o755)?; - actions.push(HookAction::ScriptInstalled); - } else if script_differs(&script_path, hook) { - write_file_with_mode(&script_path, new_script, 0o755)?; - actions.push(HookAction::ScriptUpdated); - } else { - actions.push(HookAction::ScriptUpToDate); - } - - // Wrapper: per Python's status branches. - match wrapper_status(&wrapper_path, hook) { - WrapperStatus::Missing => { - write_file_with_mode(&wrapper_path, new_wrapper, 0o755)?; - actions.push(HookAction::WrapperInstalled); - } - WrapperStatus::Legacy => { - if force { - write_file_with_mode(&wrapper_path, new_wrapper, 0o755)?; - actions.push(HookAction::WrapperMigrated); - } else { - actions.push(HookAction::WrapperLegacyNeedsForce); - } - } - WrapperStatus::Installed => { - actions.push(HookAction::WrapperAlreadyInstalled); - } - } - Ok(()) -} - -/// Ensure `git log` surfaces mergify notes by adding the local -/// `notes.displayRef = refs/notes/mergify/*` config. Returns `true` -/// when the config was added this run, `false` when it was already -/// present. -fn ensure_notes_display_ref(repo_dir: Option<&Path>) -> Result<bool, CliError> { - let desired = "refs/notes/mergify/*"; - let current = run_git_capture( - repo_dir, - &["config", "--local", "--get-all", "notes.displayRef"], - ) - .unwrap_or_default(); - if current.lines().any(|l| l == desired) { - return Ok(false); - } - run_git_capture( - repo_dir, - &["config", "--local", "--add", "notes.displayRef", desired], - )?; - Ok(true) -} - -/// Ensure a rewritten commit keeps its stack note by adding the -/// local `notes.rewriteRef = refs/notes/mergify/stack` config. -/// Returns `true` when the config was added this run, `false` when -/// it was already present. -/// -/// A note is addressed by commit SHA, so an amend or a rebase would -/// otherwise strand the reason on the pre-rewrite SHA and the push -/// that follows would record a blank `Reason`. This is the setting -/// that covers the rewrites git runs on its own — `git commit -/// --amend`, and the `git rebase --continue` that finishes a -/// conflicted rebase — which no flag on the CLI's own invocations -/// can reach. -fn ensure_notes_rewrite_ref(repo_dir: Option<&Path>) -> Result<bool, CliError> { - let current = run_git_capture( - repo_dir, - &["config", "--local", "--get-all", "notes.rewriteRef"], - ) - .unwrap_or_default(); - if current.lines().any(|l| l == STACK_NOTES_REF) { - return Ok(false); - } - run_git_capture( - repo_dir, - &[ - "config", - "--local", - "--add", - "notes.rewriteRef", - STACK_NOTES_REF, - ], - )?; - Ok(true) -} - -fn wrapper_status(path: &Path, hook: &str) -> WrapperStatus { - if !path.exists() { - return WrapperStatus::Missing; - } - let Ok(content) = fs::read_to_string(path) else { - return WrapperStatus::Missing; - }; - let sentinel = format!("{hook}.sh"); - if content.contains("mergify-hooks") && content.contains(&sentinel) { - return WrapperStatus::Installed; - } - if hook == "commit-msg" && content.contains("Change-Id: I${random}") { - return WrapperStatus::Legacy; - } - if hook == "prepare-commit-msg" && content.contains("is_amend_with_m_flag") { - return WrapperStatus::Legacy; - } - // User's own hook — leave it alone. - WrapperStatus::Installed -} - -fn script_differs(installed_path: &Path, hook: &str) -> bool { - let Ok(installed) = fs::read_to_string(installed_path) else { - return true; - }; - installed != script_resource(hook) -} - -fn script_resource(hook: &str) -> &'static str { - match hook { - "commit-msg" => include_str!("../../hooks/scripts/commit-msg.sh"), - "post-commit" => include_str!("../../hooks/scripts/post-commit.sh"), - "pre-push" => include_str!("../../hooks/scripts/pre-push.sh"), - "prepare-commit-msg" => include_str!("../../hooks/scripts/prepare-commit-msg.sh"), - other => panic!("unknown hook: {other}"), - } -} - -fn wrapper_resource(hook: &str) -> &'static str { - match hook { - "commit-msg" => include_str!("../../hooks/wrappers/commit-msg"), - "post-commit" => include_str!("../../hooks/wrappers/post-commit"), - "pre-push" => include_str!("../../hooks/wrappers/pre-push"), - "prepare-commit-msg" => include_str!("../../hooks/wrappers/prepare-commit-msg"), - other => panic!("unknown hook: {other}"), - } -} - -fn write_file_with_mode(path: &Path, content: &str, mode: u32) -> Result<(), CliError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| CliError::Generic(format!("create parent for {}: {e}", path.display())))?; - } - let mut f = fs::File::create(path) - .map_err(|e| CliError::Generic(format!("create {}: {e}", path.display())))?; - f.write_all(content.as_bytes()) - .map_err(|e| CliError::Generic(format!("write {}: {e}", path.display())))?; - set_executable_bit(path, mode)?; - Ok(()) -} - -#[cfg(unix)] -fn set_executable_bit(path: &Path, mode: u32) -> Result<(), CliError> { - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .map_err(|e| CliError::Generic(format!("chmod {}: {e}", path.display()))) -} - -#[cfg(not(unix))] -fn set_executable_bit(_path: &Path, _mode: u32) -> Result<(), CliError> { - // Windows: the executable bit doesn't exist; Git for Windows - // honors the shebang on POSIX-style shell scripts regardless. - Ok(()) -} - -fn resolve_hooks_dir(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> { - let raw = run_git_capture(repo_dir, &["rev-parse", "--git-path", "hooks"])?; - let path = PathBuf::from(&raw); - if path.is_absolute() { - return Ok(path); - } - // `--git-path hooks` returns a path relative to either the - // repo toplevel or the original cwd, depending on git's - // version. Anchor to repo toplevel so callers don't need to - // chdir. - let toplevel = run_git_capture(repo_dir, &["rev-parse", "--show-toplevel"])?; - Ok(PathBuf::from(toplevel).join(path)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command as StdCommand; - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - ] { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir.path()) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok); - } - dir - } - - #[test] - fn status_reports_all_missing_in_fresh_repo() { - let dir = init_repo(); - let s = status(Some(dir.path())).unwrap(); - assert_eq!(s.git_hooks.len(), HOOK_NAMES.len()); - for h in &s.git_hooks { - assert_eq!(h.wrapper_status, WrapperStatus::Missing); - assert!(!h.script_installed); - } - } - - #[test] - fn install_writes_wrappers_and_scripts() { - let dir = init_repo(); - let outcome = install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - assert_eq!(outcome.logs.len(), HOOK_NAMES.len()); - assert!(outcome.notes_display_ref_added); - let s = status(Some(dir.path())).unwrap(); - for h in &s.git_hooks { - assert_eq!(h.wrapper_status, WrapperStatus::Installed); - assert!(h.script_installed); - assert!(!h.script_needs_update); - } - } - - #[test] - fn install_is_idempotent() { - let dir = init_repo(); - install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - let second = install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - // Second run should report up-to-date scripts and already- - // installed wrappers across the board, and not re-add the - // notes.displayRef config. - assert!(!second.notes_display_ref_added); - for log in &second.logs { - assert!(log.actions.iter().any(|a| matches!( - a, - HookAction::ScriptUpToDate | HookAction::WrapperAlreadyInstalled - ))); - } - } - - #[test] - fn legacy_wrapper_needs_force() { - let dir = init_repo(); - // Plant a legacy commit-msg wrapper. - let hooks_dir = resolve_hooks_dir(Some(dir.path())).unwrap(); - fs::create_dir_all(&hooks_dir).unwrap(); - fs::write( - hooks_dir.join("commit-msg"), - "#!/bin/sh\n# legacy\nrandom=$(date +%s)\necho \"Change-Id: I${random}\"\n", - ) - .unwrap(); - - let outcome = install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - let commit_msg = outcome - .logs - .iter() - .find(|l| l.hook_name == "commit-msg") - .unwrap(); - assert!( - commit_msg - .actions - .iter() - .any(|a| matches!(a, HookAction::WrapperLegacyNeedsForce)) - ); - - // With --force, the legacy wrapper is migrated. - let outcome = install(&Options { - repo_dir: Some(dir.path()), - force: true, - }) - .unwrap(); - let commit_msg = outcome - .logs - .iter() - .find(|l| l.hook_name == "commit-msg") - .unwrap(); - assert!( - commit_msg - .actions - .iter() - .any(|a| matches!(a, HookAction::WrapperMigrated)) - ); - } - - #[test] - fn user_wrapper_is_left_alone() { - let dir = init_repo(); - let hooks_dir = resolve_hooks_dir(Some(dir.path())).unwrap(); - fs::create_dir_all(&hooks_dir).unwrap(); - let user_content = "#!/bin/sh\necho 'my custom hook'\n"; - fs::write(hooks_dir.join("commit-msg"), user_content).unwrap(); - - install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - assert_eq!( - fs::read_to_string(hooks_dir.join("commit-msg")).unwrap(), - user_content - ); - } - - #[test] - fn install_adds_notes_display_ref() { - let dir = init_repo(); - install(&Options { - repo_dir: Some(dir.path()), - force: false, - }) - .unwrap(); - let out = StdCommand::new("git") - .arg("-C") - .arg(dir.path()) - .args(["config", "--local", "--get-all", "notes.displayRef"]) - .output() - .unwrap(); - let content = String::from_utf8(out.stdout).unwrap(); - assert!(content.lines().any(|l| l == "refs/notes/mergify/*")); - } - - #[test] - fn install_adds_notes_rewrite_ref_once() { - let dir = init_repo(); - let opts = Options { - repo_dir: Some(dir.path()), - force: false, - }; - assert!(install(&opts).unwrap().notes_rewrite_ref_added); - // Second run finds it already there: no duplicate value, and - // the outcome reports nothing added so the CLI stays quiet. - assert!(!install(&opts).unwrap().notes_rewrite_ref_added); - - let out = StdCommand::new("git") - .arg("-C") - .arg(dir.path()) - .args(["config", "--local", "--get-all", "notes.rewriteRef"]) - .output() - .unwrap(); - let content = String::from_utf8(out.stdout).unwrap(); - assert_eq!( - content - .lines() - .filter(|l| *l == "refs/notes/mergify/stack") - .count(), - 1 - ); - } -} diff --git a/crates/mergify-stack/src/commands/squash.rs b/crates/mergify-stack/src/commands/squash.rs deleted file mode 100644 index 12b2b1b1..00000000 --- a/crates/mergify-stack/src/commands/squash.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! `mergify stack squash <SRC>... into <TARGET> [-m <msg>] -//! [--dry-run]` — combine several commits into a target. -//! -//! Port of `mergify_cli/stack/squash.py::stack_squash`. Reorders -//! every SRC adjacent to TARGET and rewrites the SRC verbs as -//! `fixup` (so they fold without opening an editor and TARGET's -//! message survives). When `-m` is given, the new combined -//! message is applied via an `exec git commit --amend -F <file>` -//! line inserted right after the last fixed-up commit — same -//! tempfile-leak pattern as `stack reword -m`. - -use std::io::Write; -use std::path::{Path, PathBuf}; - -use mergify_core::CliError; - -use crate::change_id; -use crate::git::{resolve_repo_toplevel, run_git_capture, shell_quote, spawn_rebase}; -use crate::local_commits::{self, LocalCommit}; -use crate::plan_display::PlanRow; -use crate::trunk; - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OrderedCommit { - sha: String, - subject: String, - change_id: String, -} - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Squash ran to completion. `plan` is the reordered full stack - /// (sources folded behind the target) with `[fixup]` tags on - /// the source rows. - Squashed { - plan: Vec<PlanRow>, - }, - /// `--dry-run` short-circuit. Same full-stack `plan`, no rebase. - DryRun { - plan: Vec<PlanRow>, - }, - EmptyStack, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub src_prefixes: &'a [String], - pub target_prefix: &'a str, - pub message: Option<&'a str>, - pub dry_run: bool, - pub mergify_binary: &'a Path, -} - -pub fn run(opts: &Options<'_>) -> Result<Outcome, CliError> { - if opts.src_prefixes.is_empty() { - return Err(CliError::InvalidState( - "at least one source commit required".to_string(), - )); - } - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let trunk = trunk::get_trunk(Some(&repo_dir)).map_err(|e| { - CliError::StackNotFound(format!( - "could not determine trunk branch ({e}). Please set \ - upstream tracking or set a base manually." - )) - })?; - let base = run_git_capture(Some(&repo_dir), &["merge-base", &trunk.refspec(), "HEAD"])?; - let commits = local_commits::read(&repo_dir, &base, "HEAD")?; - if commits.is_empty() { - return Ok(Outcome::EmptyStack); - } - - let target = match_commit(opts.target_prefix, &commits)?; - - let mut srcs: Vec<OrderedCommit> = Vec::with_capacity(opts.src_prefixes.len()); - let mut seen_src = std::collections::HashSet::new(); - for prefix in opts.src_prefixes { - let matched = match_commit(prefix, &commits)?; - if matched.sha == target.sha { - return Err(CliError::InvalidState( - "a source commit cannot be the same as the target".to_string(), - )); - } - if !seen_src.insert(matched.sha.clone()) { - return Err(CliError::InvalidState(format!( - "duplicate — source prefix '{prefix}' resolves to the same commit as another" - ))); - } - srcs.push(matched); - } - - // Build new order: non-src commits in their original order, - // with the src list reinserted immediately after target. - let src_sha_set: std::collections::HashSet<&str> = - srcs.iter().map(|s| s.sha.as_str()).collect(); - let mut new_order: Vec<OrderedCommit> = Vec::with_capacity(commits.len()); - for c in &commits { - if src_sha_set.contains(c.commit_sha.as_str()) { - continue; - } - new_order.push(OrderedCommit { - sha: c.commit_sha.clone(), - subject: c.title.clone(), - change_id: c.change_id.clone(), - }); - if c.commit_sha == target.sha { - new_order.extend(srcs.iter().cloned()); - } - } - - let plan: Vec<PlanRow> = new_order - .iter() - .map(|c| PlanRow { - sha: c.sha.clone(), - subject: c.subject.clone(), - change_id: c.change_id.clone(), - action: src_sha_set - .contains(c.sha.as_str()) - .then(|| "fixup".to_string()), - }) - .collect(); - - if opts.dry_run { - return Ok(Outcome::DryRun { plan }); - } - - let ordered_shas: Vec<String> = new_order.iter().map(|c| c.sha.clone()).collect(); - let fixup_shas: Vec<String> = srcs.iter().map(|s| s.sha.clone()).collect(); - let (exec_after_sha, exec_command) = if let Some(msg) = opts.message { - let msg_path = write_temp_message(msg)?; - let command = format!( - "git commit --amend -F {}", - shell_quote(&msg_path.to_string_lossy()) - ); - let last_src = srcs.last().expect("non-empty validated above").sha.clone(); - (Some(last_src), Some(command)) - } else { - (None, None) - }; - - spawn_squash_rebase( - &repo_dir, - &base, - opts.mergify_binary, - &ordered_shas, - &fixup_shas, - exec_after_sha.as_deref(), - exec_command.as_deref(), - )?; - Ok(Outcome::Squashed { plan }) -} - -fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<OrderedCommit, CliError> { - let (matches, field): (Vec<&LocalCommit>, &str) = if change_id::is_prefix(prefix) { - ( - commits - .iter() - .filter(|c| c.change_id.starts_with(prefix)) - .collect(), - "Change-Id", - ) - } else { - ( - commits - .iter() - .filter(|c| c.commit_sha.starts_with(prefix)) - .collect(), - "SHA", - ) - }; - match matches.as_slice() { - [] => Err(crate::match_commit::not_found(field, prefix)), - [only] => Ok(OrderedCommit { - sha: only.commit_sha.clone(), - subject: only.title.clone(), - change_id: only.change_id.clone(), - }), - many => { - let candidates: Vec<crate::match_commit::Candidate<'_>> = many - .iter() - .map(|c| crate::match_commit::Candidate { - commit_sha: &c.commit_sha, - title: &c.title, - change_id: &c.change_id, - }) - .collect(); - Err(crate::match_commit::ambiguous(field, prefix, &candidates)) - } - } -} - -fn write_temp_message(message: &str) -> Result<PathBuf, CliError> { - let mut tmp = tempfile::Builder::new() - .prefix("mergify_squash_msg_") - .suffix(".txt") - .tempfile() - .map_err(|e| CliError::Generic(format!("create squash tempfile: {e}")))?; - tmp.write_all(message.as_bytes()) - .map_err(|e| CliError::Generic(format!("write squash tempfile: {e}")))?; - tmp.flush() - .map_err(|e| CliError::Generic(format!("flush squash tempfile: {e}")))?; - let (_, path) = tmp - .keep() - .map_err(|e| CliError::Generic(format!("persist squash tempfile: {e}")))?; - Ok(path) -} - -fn spawn_squash_rebase( - repo_dir: &Path, - base: &str, - mergify_binary: &Path, - ordered_shas: &[String], - fixup_shas: &[String], - exec_after_sha: Option<&str>, - exec_command: Option<&str>, -) -> Result<(), CliError> { - let bin = shell_quote(&mergify_binary.to_string_lossy()); - let ordered = shell_quote(&ordered_shas.join(",")); - let fixup = shell_quote(&fixup_shas.join(",")); - let mut editor = format!( - "{bin} _internal rebase-todo-rewrite --action squash --shas {ordered} --fixup-shas {fixup}" - ); - if let (Some(after), Some(cmd)) = (exec_after_sha, exec_command) { - editor.push_str(" --sha "); - editor.push_str(&shell_quote(after)); - editor.push_str(" --command "); - editor.push_str(&shell_quote(cmd)); - } - spawn_rebase(repo_dir, base, Some(&editor), false) -} diff --git a/crates/mergify-stack/src/commands/sync.rs b/crates/mergify-stack/src/commands/sync.rs deleted file mode 100644 index 9cff75c2..00000000 --- a/crates/mergify-stack/src/commands/sync.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! `mergify stack sync [--dry-run]` — rebase the current stack -//! onto the trunk, dropping commits whose PR has already been -//! merged. -//! -//! Port of `mergify_cli/stack/sync.py::stack_sync`. The flow: -//! -//! 1. Validate the local branch isn't auto-generated by -//! `stack push` (see [`crate::stack_context::check_local_branch`]). -//! 2. Resolve the GitHub repository, the merge-base with the -//! trunk (`git merge-base --fork-point`), and the stack's -//! remote PRs. -//! 3. Bucket local commits via -//! [`crate::sync_status::classify`] — merged vs. remaining. -//! 4. `--dry-run` short-circuits with a plan. Otherwise fetch -//! the trunk and either: -//! - `git pull --rebase` when nothing is merged, or -//! - `git rebase -i` with `GIT_SEQUENCE_EDITOR` pointing at -//! this binary's `_internal rebase-todo-rewrite --action -//! drop --shas <merged>` to drop the merged commits in a -//! single rebase pass. - -use std::path::Path; - -use mergify_core::CliError; -use mergify_core::HttpClient; - -use crate::git::{ - compute_base_commit_sha, notes_rewrite_config, resolve_repo_toplevel, run_git_silent, - shell_quote, spawn_rebase, spawn_rebase_captured, -}; -use crate::local_commits; -use crate::remote_changes; -use crate::stack_context; -use crate::sync_status::{self, SyncStatus}; -use crate::trunk; - -#[derive(Debug, Clone)] -pub enum Outcome { - /// Plan was rendered (dry-run path). - DryRun(SyncStatus), - /// Real sync ran to completion. `dropped_count` is what we - /// rebased away. - Synced { - status: SyncStatus, - dropped_count: usize, - }, -} - -pub struct Options<'a> { - pub repo_dir: Option<&'a Path>, - pub client: &'a HttpClient, - pub user: &'a str, - pub repo: &'a str, - pub author: &'a str, - pub branch_prefix: &'a str, - /// `(remote, branch)` of the trunk. Required. - pub trunk: (&'a str, &'a str), - pub dry_run: bool, - pub mergify_binary: &'a Path, - /// Capture the rebase's git output instead of letting it reach - /// the terminal — set by `stack push`, which drives the rebase - /// under a progress spinner that owns the terminal. - pub quiet: bool, - /// Remote changes already fetched by the caller (the `stack push` - /// pre-flight). When `Some`, `run` reuses them instead of - /// re-running the per-branch PR lookups that `get_remote_changes` - /// does. `None` for the standalone `stack sync` command. - pub prefetched_remote_changes: Option<Vec<remote_changes::RemoteChange>>, - /// Skip the `git fetch <remote> <base>` pre-rebase. Set by - /// `stack push`, whose pre-flight already fetched the trunk. - pub skip_trunk_fetch: bool, -} - -pub async fn run(opts: &Options<'_>) -> Result<Outcome, CliError> { - let repo_dir = resolve_repo_toplevel(opts.repo_dir)?; - let dest_branch = trunk::git_get_branch_name(Some(&repo_dir))?; - - stack_context::check_local_branch(&dest_branch, opts.branch_prefix)?; - - let (remote, base_branch) = opts.trunk; - if base_branch == dest_branch { - return Err(stack_context::targets_itself_error( - Some(&repo_dir), - &dest_branch, - remote, - base_branch, - )); - } - - let stack_prefix = if opts.branch_prefix.is_empty() { - dest_branch.clone() - } else { - format!("{prefix}/{dest_branch}", prefix = opts.branch_prefix) - }; - - let trunk_ref = format!("{remote}/{base_branch}"); - let base_commit_sha = compute_base_commit_sha(&repo_dir, &trunk_ref, &dest_branch)?; - - let local = local_commits::read(&repo_dir, &base_commit_sha, "HEAD")?; - let remote_changes = match &opts.prefetched_remote_changes { - Some(changes) => changes.clone(), - None => { - remote_changes::get_remote_changes( - opts.client, - opts.user, - opts.repo, - &stack_prefix, - Some(opts.author), - &local, - ) - .await? - } - }; - let status = sync_status::classify( - dest_branch.clone(), - trunk_ref.clone(), - &local, - remote_changes, - )?; - - if opts.dry_run { - return Ok(Outcome::DryRun(status)); - } - - // Real sync. - if !opts.skip_trunk_fetch { - run_git_silent(Some(&repo_dir), &["fetch", remote, base_branch])?; - } - let dropped_count = status.merged.len(); - if status.up_to_date() || status.all_merged() { - // Either nothing to drop, or every commit got merged. - // Both cases collapse to a plain pull --rebase. - let notes_config = notes_rewrite_config(); - let mut args: Vec<&str> = notes_config.iter().map(String::as_str).collect(); - args.extend(["pull", "--rebase", remote, base_branch]); - run_git_silent(Some(&repo_dir), &args).map_err( - // A failed `pull --rebase` is almost always a conflict; - // give the same recovery guidance and Conflict exit code as - // `spawn_rebase` rather than a bare stderr. (`run_git_silent` - // only ever returns `Generic`, so map unconditionally.) - |_| { - CliError::Conflict( - "rebase failed — there may be conflicts\n\ - Resolve conflicts then run: git rebase --continue\n\ - Or abort the rebase with: git rebase --abort" - .to_string(), - ) - }, - )?; - } else { - // Mixed case: drop merged commits in a single - // `git rebase -i`, dispatched through the existing - // rebase-todo `Action::Drop` machinery. - let merged_shas: Vec<String> = status.merged.iter().map(|m| m.commit_sha.clone()).collect(); - let editor = build_drop_editor(opts.mergify_binary, &merged_shas); - let trunk_ref_owned = trunk_ref.clone(); - // Trunk already carries the squash-merged equivalents of the - // commits we're dropping, so they must stay in the todo for - // the drop editor to find them — see `spawn_rebase`. - if opts.quiet { - spawn_rebase_captured(&repo_dir, &trunk_ref_owned, Some(&editor), true)?; - } else { - spawn_rebase(&repo_dir, &trunk_ref_owned, Some(&editor), true)?; - } - } - Ok(Outcome::Synced { - status, - dropped_count, - }) -} - -fn build_drop_editor(binary: &Path, merged_shas: &[String]) -> String { - let bin = shell_quote(&binary.to_string_lossy()); - let shas = shell_quote(&merged_shas.join(",")); - format!("{bin} _internal rebase-todo-rewrite --action drop --shas {shas}") -} diff --git a/crates/mergify-stack/src/comment_upsert.rs b/crates/mergify-stack/src/comment_upsert.rs deleted file mode 100644 index 3fb23b9f..00000000 --- a/crates/mergify-stack/src/comment_upsert.rs +++ /dev/null @@ -1,546 +0,0 @@ -//! Per-PR writers for the two sticky comments `stack push` -//! maintains: -//! -//! - [`update_stack_comment_for_pull`] — the "this PR is part -//! of a stack" table (see [`crate::stack_comment`]). Skipped -//! when the stack has only one PR — a single-row table would -//! be noise. Only reached by a push whose stack GitHub did not -//! register: a registered stack has GitHub's own UI listing -//! its members, and gets [`remove_stack_comment_for_pull`] -//! instead. -//! - [`remove_stack_comment_for_pull`] — the same comment's -//! teardown, for the stacks GitHub now describes itself. -//! - [`upsert_revision_history_comment`] — the "Revision -//! history" table (see [`crate::revision_history`]). The -//! revision-history body is pre-rendered by the push -//! orchestrator from the git-notes history (see -//! [`crate::revision_note`]); this module only diffs the -//! rendered body against the existing comment and writes it. -//! GitHub renders nothing like it, so nothing here replaces -//! it. -//! -//! All three walk the issue comments once and match on the header -//! (`StackComment::is_stack_comment` / `RevisionHistoryComment:: -//! is_revision_comment`). The upserters then choose between PATCH -//! (existing found, body changed), no-op (existing found, body -//! unchanged), and POST (no existing); the remover issues a DELETE -//! for what it finds. -//! -//! Ported from -//! `mergify_cli/stack/push.py::{_update_comment_for_pull, -//! _update_revision_for_pull}`. The orchestration around them -//! (fan-out via `asyncio.Semaphore`, filter merged PRs out, …) -//! lives in the eventual `stack_push` orchestrator port — these -//! are pure single-PR helpers. - -use mergify_core::{CliError, HttpClient}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use url::Url; - -use crate::revision_history::RevisionHistoryComment; -use crate::stack_comment::{self, StackEntry}; - -#[derive(Debug, Deserialize)] -struct Comment { - url: String, - body: String, -} - -#[derive(Serialize)] -struct BodyOnly<'a> { - body: &'a str, -} - -/// GitHub's comments endpoint returns `url` as an absolute URL, -/// but the HTTP client rejects absolute URLs to prevent base-URL -/// hijacking. Extract the path so the client can re-join it -/// against its known base. -fn comment_path(absolute_url: &str) -> Result<String, CliError> { - let parsed = Url::parse(absolute_url).map_err(|e| { - CliError::GitHubApi(format!( - "comment payload had unparseable `url`: {absolute_url:?} ({e})", - )) - })?; - let path = parsed.path(); - if let Some(query) = parsed.query() { - Ok(format!("{path}?{query}")) - } else { - Ok(path.to_string()) - } -} - -/// Upsert the stack-comment for one PR. -/// -/// Callers must have established that GitHub is not describing -/// this stack itself — see [`remove_stack_comment_for_pull`] for -/// why, and [`crate::commands::push`] for where that is decided. -/// -/// `total_pulls` is the count of live PRs in the stack (i.e. -/// the number of `entries`). When it's 1, skip *creation* — -/// a single-row "stack" table is noise on a non-stacked PR. -/// Existing comments are still updated in place (matches -/// Python's `_update_comment_for_pull`): the early-exit only -/// gates the POST. -/// -/// `pull_number` is the PR being decorated — drives both the -/// 👈 marker placement in the rendered table and the -/// `is_current` flag in the JSON marker. -pub async fn update_stack_comment_for_pull( - client: &HttpClient, - user: &str, - repo: &str, - pull_number: u64, - entries: &[StackEntry], - stack_id: &str, - total_pulls: usize, -) -> Result<(), CliError> { - let new_body = stack_comment::body(entries, pull_number, stack_id); - - let path = format!("/repos/{user}/{repo}/issues/{pull_number}/comments"); - let comments: Vec<Comment> = client.get(&path).await?; - - for comment in &comments { - if stack_comment::is_stack_comment(&comment.body) { - if comment.body != new_body { - let _: Value = client - .patch(&comment_path(&comment.url)?, &BodyOnly { body: &new_body }) - .await?; - } - return Ok(()); - } - } - - // No existing comment. Skip when there's only one PR in the - // stack — single-row stack table is noise. - if total_pulls <= 1 { - return Ok(()); - } - - let _: Value = client.post(&path, &BodyOnly { body: &new_body }).await?; - Ok(()) -} - -/// Take the stack comment down from one PR, if it is still there. -/// -/// GitHub's native Stacks UI lists a registered stack's members on -/// the pull request page itself, so our sticky table is a second -/// copy of what the reader is already looking at and -/// [`crate::commands::push`] stops writing it. Leaving the ones -/// earlier pushes posted is not an option: nothing refreshes them -/// any more, so each would go on showing the stack's shape as of -/// the last pre-native push — a frozen table disagreeing with the -/// live list beside it, which is worse than no table at all. -/// -/// Returns whether a comment was found and deleted. -pub async fn remove_stack_comment_for_pull( - client: &HttpClient, - user: &str, - repo: &str, - pull_number: u64, -) -> Result<bool, CliError> { - let path = format!("/repos/{user}/{repo}/issues/{pull_number}/comments"); - let comments: Vec<Comment> = client.get(&path).await?; - - for comment in &comments { - if stack_comment::is_stack_comment(&comment.body) { - // 404 between the GET and the DELETE is somebody else - // having removed it — the end state we wanted. - client - .delete_if_exists(&comment_path(&comment.url)?) - .await?; - return Ok(true); - } - } - - Ok(false) -} - -/// Upsert the revision-history comment with a pre-rendered body. -/// -/// The body is rendered by the caller from the git-notes history -/// (the machine source of truth — see [`crate::revision_note`]); -/// this function never parses comment content. Find our comment -/// by header → PATCH if the body differs (no-op if identical); -/// none found → POST. -pub async fn upsert_revision_history_comment( - client: &HttpClient, - user: &str, - repo: &str, - pull_number: u64, - new_body: &str, -) -> Result<(), CliError> { - let path = format!("/repos/{user}/{repo}/issues/{pull_number}/comments"); - let comments: Vec<Comment> = client.get(&path).await?; - - for comment in &comments { - if !RevisionHistoryComment::is_revision_comment(&comment.body) { - continue; - } - if comment.body != new_body { - let _: Value = client - .patch(&comment_path(&comment.url)?, &BodyOnly { body: new_body }) - .await?; - } - return Ok(()); - } - - let _: Value = client.post(&path, &BodyOnly { body: new_body }).await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{DateTime, TimeZone, Utc}; - use mergify_core::ApiFlavor; - use serde_json::json; - use url::Url; - use wiremock::matchers::{method, path as wm_path}; - use wiremock::{Mock, MockServer, Request, ResponseTemplate}; - - use crate::change_type::ChangeType; - - fn client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "token", - ApiFlavor::GitHub, - ) - .unwrap() - } - - fn request_body(req: &Request) -> Value { - serde_json::from_slice(&req.body).expect("body is json") - } - - fn entry(number: u64, change_id: &str) -> StackEntry { - StackEntry { - number, - change_id: change_id.into(), - head_sha: "1111111111111111111111111111111111111111".into(), - base_branch: "main".into(), - dest_branch: format!("jd/feature/{}", &change_id[..7]), - title: format!("feat: change {number}"), - html_url: format!("https://github.com/o/r/pull/{number}"), - } - } - - fn ts() -> DateTime<Utc> { - Utc.with_ymd_and_hms(2026, 6, 4, 12, 30, 0).unwrap() - } - - #[tokio::test] - async fn stack_comment_creates_when_missing() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - - let entries = vec![ - entry(1, "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), - entry(2, "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - ]; - update_stack_comment_for_pull(&client(&server), "o", "r", 1, &entries, "feat", 2) - .await - .unwrap(); - - let last = server.received_requests().await.unwrap(); - let post = last.iter().find(|r| r.method.as_str() == "POST").unwrap(); - let body = request_body(post); - assert!( - body["body"] - .as_str() - .unwrap() - .starts_with("This pull request is part of a [Mergify stack]") - ); - } - - #[tokio::test] - async fn stack_comment_patches_when_existing_body_differs() { - let server = MockServer::start().await; - // The existing comment has our header — recognised as - // ours — but stale content; the upserter must PATCH it - // (not POST a duplicate). - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/100", server.uri()), - "body": "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\nSTALE", - }, - ]))) - .mount(&server) - .await; - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/issues/comments/100")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let entries = vec![ - entry(1, "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), - entry(2, "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), - ]; - update_stack_comment_for_pull(&client(&server), "o", "r", 1, &entries, "feat", 2) - .await - .unwrap(); - - let reqs = server.received_requests().await.unwrap(); - let patch = reqs.iter().find(|r| r.method.as_str() == "PATCH").unwrap(); - let body = request_body(patch); - // Fresh body has the marker and 👈 on row 1. - assert!( - body["body"] - .as_str() - .unwrap() - .contains("<!-- mergify-stack-data: ") - ); - assert!(body["body"].as_str().unwrap().contains("👈")); - } - - #[tokio::test] - async fn stack_comment_skips_when_only_one_pull() { - // total_pulls == 1 → no POST. A single-row table for a - // standalone PR is noise; the orchestrator skips the - // upsert entirely. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .mount(&server) - .await; - // No POST mock — if the code tried to POST, wiremock - // would 404 and the call would fail. - - let entries = vec![entry(1, "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")]; - update_stack_comment_for_pull(&client(&server), "o", "r", 1, &entries, "feat", 1) - .await - .unwrap(); - } - - #[tokio::test] - async fn remove_stack_comment_deletes_ours_and_leaves_the_rest() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/99", server.uri()), - "body": "LGTM", - }, - { - "url": format!("{}/repos/o/r/issues/comments/100", server.uri()), - "body": "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\nTABLE", - }, - { - "url": format!("{}/repos/o/r/issues/comments/101", server.uri()), - "body": "### Revision history\n\nKEEP", - }, - ]))) - .expect(1) - .mount(&server) - .await; - // Only comment 100 has a DELETE stubbed: a DELETE aimed at - // the human comment or at the revision history would 404 - // against the mock and fail the call. - Mock::given(method("DELETE")) - .and(wm_path("/repos/o/r/issues/comments/100")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let removed = remove_stack_comment_for_pull(&client(&server), "o", "r", 1) - .await - .unwrap(); - assert!(removed); - } - - #[tokio::test] - async fn remove_stack_comment_recognises_the_legacy_header() { - // Comments posted before the docs link went into the header - // are still ours to take down — the whole point of the - // removal is the backlog of pre-native pushes. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/100", server.uri()), - "body": "This pull request is part of a stack:\nTABLE", - }, - ]))) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(wm_path("/repos/o/r/issues/comments/100")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - assert!( - remove_stack_comment_for_pull(&client(&server), "o", "r", 1) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn remove_stack_comment_is_a_noop_when_there_is_none() { - // The steady state once this has shipped: nothing to delete, - // so no write request at all. No DELETE mock — one would 404. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/101", server.uri()), - "body": "### Revision history\n\nKEEP", - }, - ]))) - .expect(1) - .mount(&server) - .await; - - let removed = remove_stack_comment_for_pull(&client(&server), "o", "r", 1) - .await - .unwrap(); - assert!(!removed); - } - - #[tokio::test] - async fn remove_stack_comment_treats_a_vanished_comment_as_removed() { - // Someone deleted it between our GET and our DELETE. That is - // the end state we were after, not a failure. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/1/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/100", server.uri()), - "body": "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\nTABLE", - }, - ]))) - .mount(&server) - .await; - Mock::given(method("DELETE")) - .and(wm_path("/repos/o/r/issues/comments/100")) - .respond_with(ResponseTemplate::new(404)) - .expect(1) - .mount(&server) - .await; - - assert!( - remove_stack_comment_for_pull(&client(&server), "o", "r", 1) - .await - .unwrap() - ); - } - - #[tokio::test] - async fn revision_comment_posts_when_missing() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .expect(1) - .mount(&server) - .await; - - let body = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "aaaaaaaaaaaa", - "bbbbbbbbbbbb", - ChangeType::Content, - ts(), - "review feedback", - None, - ) - .body(42); - upsert_revision_history_comment(&client(&server), "o", "r", 42, &body) - .await - .unwrap(); - - let reqs = server.received_requests().await.unwrap(); - let post = reqs.iter().find(|r| r.method.as_str() == "POST").unwrap(); - let sent = request_body(post); - assert_eq!(sent["body"].as_str().unwrap(), body); - } - - #[tokio::test] - async fn revision_comment_patches_when_body_differs() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/200", server.uri()), - "body": "### Revision history\n\nSTALE", - }, - ]))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/issues/comments/200")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .expect(1) - .mount(&server) - .await; - - upsert_revision_history_comment( - &client(&server), - "o", - "r", - 42, - "### Revision history\n\nNEW", - ) - .await - .unwrap(); - } - - #[tokio::test] - async fn revision_comment_noops_when_body_identical() { - // Same body → neither PATCH nor POST. Only the GET runs; any - // write request would 404 against the mock server and fail. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - { - "url": format!("{}/repos/o/r/issues/comments/200", server.uri()), - "body": "### Revision history\n\nSAME", - }, - ]))) - .expect(1) - .mount(&server) - .await; - - upsert_revision_history_comment( - &client(&server), - "o", - "r", - 42, - "### Revision history\n\nSAME", - ) - .await - .unwrap(); - } -} diff --git a/crates/mergify-stack/src/git.rs b/crates/mergify-stack/src/git.rs deleted file mode 100644 index 23c7caf5..00000000 --- a/crates/mergify-stack/src/git.rs +++ /dev/null @@ -1,557 +0,0 @@ -//! Shared git-invocation helpers used by every `commands::*` -//! module + the leaf git-using crates (`change_type`, -//! `notes_push`, `replay`, …). -//! -//! Centralises the locale-forcing (`LC_ALL=C` etc. — several call -//! sites parse English git error messages), the `-C <repo_dir>` -//! prefix, and the "non-zero exit ⇒ `CliError::Generic` with the -//! captured stderr" mapping so the per-command modules don't have -//! to maintain their own slight variations of each helper. - -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use mergify_core::CliError; - -use crate::local_commits::STACK_NOTES_REF; - -/// `-c` overrides that make git carry the stack's notes from every -/// rewritten commit onto its replacement. -/// -/// A note is addressed by commit SHA, so without this a rebase -/// strands the amend reason on the pre-rebase SHA and the push that -/// follows records a blank `Reason` in the revision history. -/// `mergify stack setup` persists only `notes.rewriteRef` into the -/// repo config — the mode and the rebase toggle are git's defaults -/// there. That config is what covers the rewrites git runs outside -/// this process (`git commit --amend`, and the `git rebase -/// --continue` that finishes a conflicted rebase); passing all three -/// per-invocation keeps the CLI's own rebases correct in repos whose -/// config predates that, and pins the defaults against a global -/// `notes.rewriteMode=ignore` or `notes.rewrite.rebase=false`. -#[must_use] -pub(crate) fn notes_rewrite_config() -> Vec<String> { - vec![ - "-c".to_string(), - format!("notes.rewriteRef={STACK_NOTES_REF}"), - "-c".to_string(), - "notes.rewriteMode=concatenate".to_string(), - "-c".to_string(), - "notes.rewrite.rebase=true".to_string(), - ] -} - -/// Base `git` `Command` with `-C <repo_dir>` (when supplied) and a -/// forced C locale. Use for any git invocation whose stderr or -/// stdout the caller might parse — translated locales would -/// otherwise break the substring matches `change_type`, -/// `notes_push`, and the rebase helpers rely on. -#[must_use] -pub fn git_cmd(repo_dir: Option<&Path>) -> Command { - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - cmd.env("LC_ALL", "C").env("LANG", "C").env("LANGUAGE", "C"); - cmd -} - -/// Run `git <args>` and return trimmed stdout. -/// -/// Non-zero exit surfaces the trimmed stderr (or a generic -/// `git <args> failed` when stderr is empty) wrapped in -/// [`CliError::Generic`]; non-UTF-8 stdout is the same error -/// class. -pub fn run_git_capture(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> { - let stdout = run_git_capture_raw(repo_dir, args)?; - let stdout = String::from_utf8(stdout).map_err(|e| { - CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" "))) - })?; - Ok(stdout.trim_end().to_string()) -} - -/// Run `git <args>` and return raw, **untrimmed** stdout. -/// -/// The byte-level counterpart of [`run_git_capture`], for the -/// callers that pass `-z`: NUL-delimited output must not be -/// trimmed (a leading or trailing space is a legal filename -/// character) and must not be forced through UTF-8 before the -/// caller has a chance to name the entry that isn't. Same -/// spawn/exit error mapping as [`run_git_capture`]. -pub(crate) fn run_git_capture_raw( - repo_dir: Option<&Path>, - args: &[&str], -) -> Result<Vec<u8>, CliError> { - let output = git_cmd(repo_dir) - .args(args) - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(CliError::Generic(if stderr.is_empty() { - format!("`git {}` failed", args.join(" ")) - } else { - stderr - })); - } - Ok(output.stdout) -} - -/// Run `git <args>` discarding stdout. Useful for `fetch`, `push`, -/// `update-ref` and friends where the caller only cares about -/// success. -pub fn run_git_silent(repo_dir: Option<&Path>, args: &[&str]) -> Result<(), CliError> { - let output = git_cmd(repo_dir) - .args(args) - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(CliError::Generic(if stderr.is_empty() { - format!("`git {}` failed", args.join(" ")) - } else { - stderr - })); - } - Ok(()) -} - -/// `git rev-parse --show-toplevel` resolved to a `PathBuf`. Almost -/// every stack subcommand needs this on entry so the rest of the -/// flow operates on an absolute repo root regardless of the -/// user's CWD. -pub fn resolve_repo_toplevel(repo_dir: Option<&Path>) -> Result<PathBuf, CliError> { - Ok(PathBuf::from(run_git_capture( - repo_dir, - &["rev-parse", "--show-toplevel"], - )?)) -} - -/// `git rebase -i <base>` with `GIT_SEQUENCE_EDITOR` pointing at -/// the caller-built editor command (or with the user's editor -/// when `sequence_editor` is `None` — the interactive `stack -/// edit` path). Used by every rebase-driving stack subcommand -/// to dispatch back into the rebase-todo rewriter without -/// spawning a real interactive editor (or falling through to -/// it when the caller wants the user in the loop). -/// -/// `reapply_cherry_picks` maps to git's `--reapply-cherry-picks`; -/// set it when the editor drops commits by SHA against a base -/// that already contains their squash-merged equivalents. -/// -/// Inherits the terminal — git's rebase progress prints live. Use -/// [`spawn_rebase_captured`] when a progress spinner owns the -/// terminal and git's output would corrupt the in-place redraw. -pub fn spawn_rebase( - repo_dir: &Path, - base: &str, - sequence_editor: Option<&str>, - reapply_cherry_picks: bool, -) -> Result<(), CliError> { - spawn_rebase_inner(repo_dir, base, sequence_editor, reapply_cherry_picks, false) -} - -/// Like [`spawn_rebase`] but captures git's output instead of -/// letting it reach the terminal — for spinner-driven rebases. On -/// success the output is discarded; on failure it's flushed to the -/// real streams first so a conflict is never swallowed. -pub fn spawn_rebase_captured( - repo_dir: &Path, - base: &str, - sequence_editor: Option<&str>, - reapply_cherry_picks: bool, -) -> Result<(), CliError> { - spawn_rebase_inner(repo_dir, base, sequence_editor, reapply_cherry_picks, true) -} - -fn spawn_rebase_inner( - repo_dir: &Path, - base: &str, - sequence_editor: Option<&str>, - reapply_cherry_picks: bool, - capture: bool, -) -> Result<(), CliError> { - let mut cmd = git_cmd(Some(repo_dir)); - cmd.args(notes_rewrite_config()); - cmd.arg("rebase").arg("-i"); - if reapply_cherry_picks { - // Keep commits whose patch-id already matches the base in - // the rebase todo. Git's default (`--no-reapply-cherry-picks`) - // silently omits them, which breaks callers that hand the - // rebase-todo rewriter an explicit drop list: the dropped - // SHAs would have no `pick` line left and the rewrite aborts. - // Letting them through keeps the drop editor the sole - // authority on what the rebase removes. - cmd.arg("--reapply-cherry-picks"); - } - cmd.arg(base); - if let Some(editor) = sequence_editor { - cmd.env("GIT_SEQUENCE_EDITOR", editor); - } - - let (success, stderr) = if capture { - // Null stdin so a mid-rebase prompt (e.g. an unexpected - // interactive editor invocation) fails fast instead of - // blocking invisibly behind the progress spinner that owns - // the terminal. - cmd.stdin(std::process::Stdio::null()); - let output = cmd - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))?; - if !output.status.success() { - // The spinner suppressed git's output, so replay it now - // that we've hit a failure and the user needs to see it. - let _ = std::io::stdout().write_all(&output.stdout); - let _ = std::io::stderr().write_all(&output.stderr); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - (output.status.success(), stderr) - } else { - let success = cmd - .status() - .map_err(|e| CliError::Generic(format!("failed to spawn `git rebase -i`: {e}")))? - .success(); - (success, String::new()) - }; - - if !success { - if rebase_in_progress(repo_dir) { - // An interrupted rebase left a `rebase-merge`/`rebase-apply` - // state dir — this is the conflict case. Surface the - // recovery steps and map to the dedicated Conflict exit - // code (4) so scripts can branch on it — same contract as - // the Python `run_scripted_rebase`. - return Err(CliError::Conflict( - "rebase failed — there may be conflicts\n\ - Resolve conflicts then run: git rebase --continue\n\ - Or abort the rebase with: git rebase --abort" - .to_string(), - )); - } - // No rebase in progress: git failed before/while starting - // (bad base, todo-editor error, …). The continue/abort advice - // would be misleading, so report the failure plainly. - return Err(CliError::Generic(if stderr.is_empty() { - "git rebase failed".to_string() - } else { - format!("git rebase failed: {stderr}") - })); - } - Ok(()) -} - -/// Whether an interrupted rebase left a state directory under the -/// repo's git dir. Distinguishes a real conflict (recoverable with -/// `--continue`/`--abort`) from a rebase that failed before it -/// started, which has no such state. -fn rebase_in_progress(repo_dir: &Path) -> bool { - let git_dir = run_git_capture(Some(repo_dir), &["rev-parse", "--git-dir"]) - .map_or_else(|_| repo_dir.join(".git"), PathBuf::from); - // `git rev-parse --git-dir` may return a path relative to - // `repo_dir`; resolve it against the repo so the existence check - // works regardless of the process CWD. - let git_dir = if git_dir.is_absolute() { - git_dir - } else { - repo_dir.join(git_dir) - }; - git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists() -} - -/// Base commit SHA of the stack: the merge-base between the trunk -/// ref and `HEAD`. -/// -/// `--fork-point` is the precise answer when the reflog has history; -/// falls back to a plain merge-base for fresh clones / CI sandboxes -/// where the reflog is empty. An empty result from both is a -/// [`CliError::StackNotFound`] — the branch shares no history with -/// the trunk. -pub fn compute_base_commit_sha( - repo_dir: &Path, - trunk_ref: &str, - dest_branch: &str, -) -> Result<String, CliError> { - if let Ok(sha) = run_git_capture(Some(repo_dir), &["merge-base", "--fork-point", trunk_ref]) - && !sha.is_empty() - { - return Ok(sha); - } - let sha = run_git_capture(Some(repo_dir), &["merge-base", trunk_ref, "HEAD"])?; - if sha.is_empty() { - return Err(CliError::StackNotFound(format!( - "common commit between `{trunk_ref}` and `{dest_branch}` branches not found", - ))); - } - Ok(sha) -} - -/// POSIX shell single-quote a value so it survives substitution -/// into `GIT_SEQUENCE_EDITOR=…`. Doubles existing single quotes -/// via the standard `'\''` trick. -#[must_use] -pub fn shell_quote(value: &str) -> String { - let escaped = value.replace('\'', "'\\''"); - format!("'{escaped}'") -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn run(path: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .unwrap(); - assert!(status.success(), "git {args:?} failed"); - } - - fn init_repo() -> TempDir { - let dir = TempDir::new().unwrap(); - let p = dir.path(); - run(p, &["init", "-q", "-b", "main"]); - run(p, &["config", "user.email", "t@e.com"]); - run(p, &["config", "user.name", "t"]); - run(p, &["config", "commit.gpgsign", "false"]); - std::fs::write(p.join("x"), "x\n").unwrap(); - run(p, &["add", "x"]); - run(p, &["commit", "-q", "-m", "init"]); - dir - } - - fn capture(path: &Path, args: &[&str]) -> String { - run_git_capture(Some(path), args).unwrap() - } - - /// A repo whose trunk already carries the squash-merged - /// equivalent of a stacked commit. Returns `(dir, [base_sha, - /// reapplied_sha, kept_sha])`: - /// - /// - `main` has `root` then `feat` (the squash-merge landing). - /// - `feature` branches off `root`, re-adds the same patch as - /// `feat` (so its patch-id matches what's now on `main`), then - /// adds a distinct `extra` commit on top. - /// - /// Rebasing `feature` onto `main` therefore needs - /// `--reapply-cherry-picks` to keep the cherry-picked commit in - /// the todo, or the drop editor has nothing to drop. - fn squash_merged_repo() -> (TempDir, String, String, String) { - let dir = init_repo(); - let p = dir.path().to_path_buf(); - let root = capture(&p, &["rev-parse", "HEAD"]); - - // The patch that gets both squash-merged to main and - // re-created on the feature branch. - std::fs::write(p.join("feat.txt"), "feature\n").unwrap(); - run(&p, &["add", "feat.txt"]); - run(&p, &["commit", "-q", "-m", "feat"]); - - // Feature branch off root: re-apply the same patch, then a - // distinct commit. - run(&p, &["checkout", "-q", "-b", "feature", &root]); - std::fs::write(p.join("feat.txt"), "feature\n").unwrap(); - run(&p, &["add", "feat.txt"]); - run(&p, &["commit", "-q", "-m", "feat (reapplied)"]); - let reapplied = capture(&p, &["rev-parse", "HEAD"]); - std::fs::write(p.join("extra.txt"), "extra\n").unwrap(); - run(&p, &["add", "extra.txt"]); - run(&p, &["commit", "-q", "-m", "extra"]); - let kept = capture(&p, &["rev-parse", "HEAD"]); - - (dir, root, reapplied, kept) - } - - #[test] - fn run_git_capture_returns_trimmed_stdout() { - let dir = init_repo(); - let out = run_git_capture(Some(dir.path()), &["rev-parse", "HEAD"]).unwrap(); - assert_eq!(out.len(), 40, "SHA1 hex without trailing newline"); - } - - /// Build a sequence editor that copies the rebase todo it's - /// handed to `todo_copy`, then deletes the reapplied commit's - /// line so the rebase can complete. Lets a test both assert on - /// the todo git produced AND drive the drop. - fn capturing_drop_editor(todo_copy: &Path) -> String { - // `$1` is the todo path git passes to the editor. - let copy = todo_copy.to_string_lossy(); - format!("sh -c 'cp \"$1\" \"{copy}\"; sed -i.bak \"/feat (reapplied)/d\" \"$1\"' --") - } - - #[test] - fn captured_rebase_drops_squash_merged_commit_via_reapply_cherry_picks() { - // Regression guard for `--reapply-cherry-picks` on the - // captured (quiet) rebase path. The flag forces git to keep - // the cherry-picked (already-on-trunk) commit in the todo so - // the drop editor has a line to remove; the test asserts the - // todo contained that line and the commit was dropped while - // the distinct `extra` commit survived. - let (dir, _root, _reapplied, kept) = squash_merged_repo(); - let p = dir.path(); - let todo = p.join("captured-todo"); - let editor = capturing_drop_editor(&todo); - - spawn_rebase_captured(p, "main", Some(&editor), true).unwrap(); - - let todo_text = std::fs::read_to_string(&todo).unwrap(); - assert!( - todo_text.contains("feat (reapplied)"), - "--reapply-cherry-picks must keep the cherry-picked commit \ - in the todo, else there's nothing to drop:\n{todo_text}" - ); - let subjects = capture(p, &["log", "--format=%s", "main..HEAD"]); - assert!( - subjects.contains("extra"), - "kept commit survives: {subjects}" - ); - assert!( - !subjects.contains("feat (reapplied)"), - "squash-merged commit dropped: {subjects}" - ); - assert_ne!(capture(p, &["rev-parse", "HEAD"]), kept); - } - - #[test] - fn captured_rebase_without_reapply_omits_cherry_picked_from_todo() { - // The negative side of the guard: dropping - // `--reapply-cherry-picks` makes git silently omit the - // already-on-trunk commit from the todo — so a drop editor - // keyed on it would find nothing. This is exactly the - // regression the positive test catches. - let (dir, _root, _reapplied, _kept) = squash_merged_repo(); - let p = dir.path(); - let todo = p.join("captured-todo"); - let editor = capturing_drop_editor(&todo); - - spawn_rebase_captured(p, "main", Some(&editor), false).unwrap(); - - let todo_text = std::fs::read_to_string(&todo).unwrap(); - assert!( - !todo_text.contains("feat (reapplied)"), - "without --reapply-cherry-picks the cherry-picked commit \ - must be absent from the todo:\n{todo_text}" - ); - } - - #[test] - fn rebase_carries_the_stack_note_onto_the_rewritten_commit() { - // The reason a user attaches with `mergify stack note` lives - // on the commit SHA, and `stack push` rebases before it reads - // it back. Without the notes-rewrite config the note stays on - // the pre-rebase SHA and the revision history records a blank - // `Reason` — this is that regression. - let dir = init_repo(); - let p = dir.path(); - - run(p, &["checkout", "-q", "-b", "feature"]); - std::fs::write(p.join("feat.txt"), "feature\n").unwrap(); - run(p, &["add", "feat.txt"]); - run(p, &["commit", "-q", "-m", "feat"]); - let notes_ref = format!("--ref={STACK_NOTES_REF}"); - run(p, &["notes", ¬es_ref, "add", "-m", "why: review fix"]); - let before = capture(p, &["rev-parse", "HEAD"]); - - // Move the trunk so the rebase has to rewrite `feat`. - run(p, &["checkout", "-q", "main"]); - std::fs::write(p.join("trunk.txt"), "trunk\n").unwrap(); - run(p, &["add", "trunk.txt"]); - run(p, &["commit", "-q", "-m", "trunk moves"]); - run(p, &["checkout", "-q", "feature"]); - - spawn_rebase_captured(p, "main", Some("true"), false).unwrap(); - - let after = capture(p, &["rev-parse", "HEAD"]); - assert_ne!(after, before, "the rebase must have rewritten the commit"); - assert_eq!( - capture(p, &["notes", ¬es_ref, "show", &after]), - "why: review fix" - ); - // Copied, not moved: the pre-rebase SHA keeps its note, which - // is what `load_or_seed` reads back off the old PR head. - assert_eq!( - capture(p, &["notes", ¬es_ref, "show", &before]), - "why: review fix" - ); - } - - #[test] - fn rebase_failure_without_conflict_is_generic_not_conflict() { - // A rebase that fails before it starts (here: an - // unresolvable base) must not hand back the conflict - // continue/abort advice — there is no rebase in progress to - // continue. - let dir = init_repo(); - let err = spawn_rebase_captured(dir.path(), "no-such-base", None, false).unwrap_err(); - match err { - CliError::Generic(msg) => { - assert!(msg.contains("rebase failed"), "got: {msg}"); - } - other => panic!("expected Generic, got: {other:?}"), - } - // No rebase state dir was left behind. - assert!(!rebase_in_progress(dir.path())); - } - - #[test] - fn run_git_capture_surfaces_stderr_on_failure() { - let dir = init_repo(); - let err = run_git_capture(Some(dir.path()), &["rev-parse", "--verify", "no-such-ref"]) - .unwrap_err(); - let CliError::Generic(msg) = err else { - panic!("expected Generic"); - }; - // git's stderr for a bad ref is e.g. - // "fatal: Needed a single revision" — assert on the - // generic "fatal:" prefix so the test isn't tied to the - // exact wording across git versions. - assert!(msg.contains("fatal"), "stderr passed through: {msg}"); - } - - #[test] - fn resolve_repo_toplevel_returns_repo_root() { - let dir = init_repo(); - let resolved = resolve_repo_toplevel(Some(dir.path())).unwrap(); - // Canonicalise both to dodge macOS `/private/var/folders/...` - // realpath divergence on tmp paths. - assert_eq!( - std::fs::canonicalize(&resolved).unwrap(), - std::fs::canonicalize(dir.path()).unwrap(), - ); - } - - #[test] - fn shell_quote_escapes_embedded_single_quotes() { - assert_eq!(shell_quote("simple"), "'simple'"); - assert_eq!(shell_quote("with 'quote'"), r"'with '\''quote'\'''"); - // Empty input still produces a valid empty-quoted token. - assert_eq!(shell_quote(""), "''"); - } - - #[test] - fn git_cmd_forces_c_locale_for_predictable_error_parsing() { - // The locale env-vars are critical — several call sites - // (notes_push, change_type) parse English git error - // messages and would break under translated locales. - let cmd = git_cmd(None); - let envs: std::collections::HashMap<_, _> = cmd - .get_envs() - .map(|(k, v)| (k.to_owned(), v.map(std::ffi::OsStr::to_owned))) - .collect(); - assert_eq!( - envs[std::ffi::OsStr::new("LC_ALL")].as_deref(), - Some(std::ffi::OsStr::new("C")) - ); - assert_eq!( - envs[std::ffi::OsStr::new("LANG")].as_deref(), - Some(std::ffi::OsStr::new("C")) - ); - assert_eq!( - envs[std::ffi::OsStr::new("LANGUAGE")].as_deref(), - Some(std::ffi::OsStr::new("C")), - ); - } -} diff --git a/crates/mergify-stack/src/lib.rs b/crates/mergify-stack/src/lib.rs deleted file mode 100644 index 6e3eac0c..00000000 --- a/crates/mergify-stack/src/lib.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Native pieces of `mergify stack`, ported from -//! `mergify_cli/stack/`. -//! -//! Today this crate ships: -//! - the stack-discovery walker that backs every stack subcommand: -//! read the local commits in `<base>..<head>`, parse each -//! commit's `Change-Id:` trailer, and return one structured -//! record per commit. The Python side reaches it via the hidden -//! `_internal stack-local-commits` subcommand on the `mergify` -//! binary; once `mergify stack list` itself is native the same -//! module is reused without the subprocess hop. -//! - [`trunk::get_trunk`] — resolve `<remote>/<branch>` for the -//! current branch, ported from `utils.get_trunk`. Used by -//! `stack new` and reusable by future `stack drop`/`stack edit` -//! ports. -//! - [`commands::new`] — the native implementation of -//! `mergify stack new`. First stack subcommand to land natively -//! (the rest still shim to Python). -//! - [`change_type`] — patch-id-based rebase-vs-content -//! classification for force-pushed PR heads, plus the -//! `refs/pull/<n>/head` fetch helper. Leaf-only port from -//! `mergify_cli/stack/push.py`; the bridge that lets Python -//! consume it ships in a follow-up. -//! - [`stack_comment`] — the "this PR is part of a stack" -//! sticky comment renderer + header recogniser. Pure -//! markdown/JSON formatting ported from -//! `mergify_cli/stack/push.py::StackComment`. Only reached -//! for a stack GitHub did not register natively — GitHub's -//! own Stacks UI is the member list everywhere else. -//! - [`replay`] — full port of `mergify_cli/stack/replay.py`: -//! `git merge-tree` + `git diff-tree` to materialise the -//! amendment, then `POST /git/trees` + `POST /git/commits` -//! to upload a synthetic commit that the revision-history -//! compare URL anchors at. -//! - [`revision_history`] — the "Revision history" sticky -//! comment renderer + parser. Ported from -//! `mergify_cli/stack/push.py::RevisionHistoryComment`. -//! - [`approvals`] — the rebase/no-rebase decision for -//! `stack push`: skip the rebase when PRs are already -//! approved (so the approvals aren't dismissed) unless the -//! bottom of the stack has a real merge conflict with -//! trunk. Ported from `mergify_cli/stack/approvals.py`. -//! - [`notes_push`] — `git fetch`/`git push` plumbing for -//! `refs/notes/mergify/stack` + the per-PR refspecs that -//! `stack push` lands atomically with `--force-with-lease`. -//! Ported from `mergify_cli/stack/push.py::{fetch_notes_ref, -//! _merge_remote_notes, push_branches}`. -//! - [`rebase_log`] — pure formatters for the three rebase -//! narration log lines emitted by `stack push`. Ported from -//! `mergify_cli/stack/push.py::{_log_rebase_performed, -//! _log_rebase_skipped, _log_rebase_dry_run}`. -//! - [`push_helpers`] — `format_pull_description` (strip -//! Change-Id + stale Depends-On, append fresh Depends-On) -//! and `build_change_tasks` (turn the planned changes into -//! the per-PR dependency graph the upserter walks). -//! - [`pr_upsert`] — the per-PR `Create` (POST `/pulls`) and -//! `Update` (PATCH `/pulls/{n}`) upserter plus the orphan -//! branch teardown. Ported from -//! `mergify_cli/stack/push.py::{create_or_update_pr, -//! delete_stack}`. -//! - [`comment_upsert`] — the two per-PR sticky-comment -//! upserters: stack comment (skip-when-single-PR) and -//! revision history (parse + append + recover-from-corrupt). -//! Ported from `mergify_cli/stack/push.py::{_update_comment_for_pull, -//! _update_revision_for_pull}`. -//! - [`plan`] — the layer above `classify` that applies the -//! `--next-only` / `--only-update-existing-pulls` overrides -//! and decorates each change with the `dest_branch` / -//! `base_branch` the upserter needs. Ported from -//! `mergify_cli/stack/changes.py::get_changes`. -//! - [`commands::push`] — the native `stack push` orchestrator -//! that wires every leaf above into the end-to-end flow. -//! Ported from `mergify_cli/stack/push.py::stack_push`. Runs -//! per-PR upserts sequentially (typical 2–5 PR stacks make -//! the latency difference negligible vs. the GitHub round- -//! trip cost). -//! - [`revision_note`] — the git-notes revision-history format -//! (human digest + the same `mergify-revision-data` marker the -//! PR comment carries), its `git notes` IO, and the -//! note-first/comment-seed read path. The machine source of -//! truth for a change's revision history, replacing the plain -//! amend-reason note `mergify stack note` wrote on the same -//! commit. - -pub mod approvals; -pub mod change_id; -pub mod change_type; -pub mod changes; -pub mod commands; -pub mod comment_upsert; -pub mod git; -pub mod local_commits; -pub mod match_commit; -pub mod native_stack; -pub mod notes_push; -pub mod plan; -pub mod plan_display; -pub mod pr_upsert; -pub mod progress; -pub mod push_helpers; -pub mod rebase_log; -pub mod rebase_todo; -pub mod remote_changes; -pub mod replay; -pub mod revision_history; -pub mod revision_note; -pub mod slug; -pub mod stack_comment; -pub mod stack_context; -pub mod sync_status; -pub mod trunk; - -#[cfg(test)] -pub(crate) mod test_env; diff --git a/crates/mergify-stack/src/local_commits.rs b/crates/mergify-stack/src/local_commits.rs deleted file mode 100644 index a8b1e761..00000000 --- a/crates/mergify-stack/src/local_commits.rs +++ /dev/null @@ -1,428 +0,0 @@ -//! Walk the local commits in `<base>..<head>` and emit one -//! structured record per commit. -//! -//! Used by every stack subcommand as the first step: read what's -//! locally in the stack, paired with each commit's `Change-Id:` -//! trailer (the stable identity that survives rewrites and pairs -//! a local commit with its remote branch + PR). -//! -//! The on-disk shape comes straight from -//! `git log --reverse --format=%H%x00%s%x00%b%x1e <range>`: -//! one record per commit, fields separated by `NUL` (`\x00`), -//! records by ASCII Record Separator (`\x1e`). Picking control -//! bytes avoids the quoting tax of `--format=` with shell-safe -//! delimiters and the parser is a fixed-shape split rather than -//! line-aware reading. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -use mergify_core::CliError; -use serde::Serialize; - -use crate::change_id; -use crate::slug; - -/// `refs/notes/mergify/stack` — the notes ref `mergify stack` -/// uses to attach amend-reason notes to commits. Read alongside -/// the commit body in the walker via `git log --notes=…` so the -/// revision-history rendering doesn't need a per-commit subprocess -/// round-trip. -pub const STACK_NOTES_REF: &str = "refs/notes/mergify/stack"; - -/// One commit in the local stack range, after parsing. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct LocalCommit { - /// Full 40-hex commit SHA. - pub commit_sha: String, - /// Commit subject line (the `%s` field). - pub title: String, - /// Full commit message body — caller-facing because some - /// stack commands need to surface it (e.g. the revision - /// history rendering reads it back). Mirrors Python's - /// `commit_infos` tuple shape. - pub message: String, - /// The trailing `Change-Id:` value extracted from `message`. - /// Always present in a `LocalCommit` — a commit missing the - /// trailer fails the walk early so partial results never - /// reach the caller. - pub change_id: String, - /// Deterministic `<slug>--<hex8>` branch suffix derived from - /// `title` + `change_id` (see [`crate::slug::slugify_title`]). - /// Used by stack commands as the default head-branch name - /// when a local commit has no remote PR paired with it yet — - /// pre-computing it here keeps the slug algorithm in one place - /// regardless of how many stack callers need it. - pub slug: String, - /// Amend-reason note attached to the commit on the - /// `refs/notes/mergify/stack` ref, or an empty string when - /// the commit has no note. The walker reads notes through - /// `git log --notes=…` so this is a free piggyback on the - /// existing per-stack-op subprocess; before this field, the - /// Python orchestrator ran one extra `git notes show <sha>` - /// per commit (N round-trips). Consumed by the revision - /// history rendering on the PR comment. - pub note: String, -} - -/// Run `git log` in `repo_dir` and parse its output into one -/// [`LocalCommit`] per commit in `<base>..<head>`. -/// -/// `base` is anything `git` accepts as a revision (typically a -/// merge-base SHA); `head` is the local stack branch name (or -/// any revision). The range is **exclusive** of `base` and -/// **inclusive** of `head`, matching git's `..` semantics. -/// -/// Errors: -/// -/// - [`CliError::Generic`] for git invocation failures (process -/// spawn errors, git exiting non-zero, unparseable output). -/// - [`CliError::InvalidState`] when a commit in the range has no -/// `Change-Id:` trailer. Mirrors Python's -/// `console_error(...); sys.exit(ExitCode.INVALID_STATE)` flow. -pub fn read(repo_dir: &Path, base: &str, head: &str) -> Result<Vec<LocalCommit>, CliError> { - let raw = run_git_log(repo_dir, base, head)?; - parse(&raw) -} - -fn run_git_log(repo_dir: &Path, base: &str, head: &str) -> Result<String, CliError> { - let range = format!("{base}..{head}"); - let notes_arg = format!("--notes={STACK_NOTES_REF}"); - let output = Command::new("git") - .arg("-C") - .arg(repo_dir) - .args([ - "log", - "--reverse", - // `%H` SHA, `%x00` NUL, `%s` subject, `%x00` NUL, - // `%b` body, `%x00` NUL, `%N` note, `%x1e` ASCII RS - // terminator. `%N` is empty when the commit has no - // note on the configured ref, so the field count is - // stable across both cases. - "--format=%H%x00%s%x00%b%x00%N%x1e", - ¬es_arg, - &range, - ]) - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git log`: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(CliError::Generic(format!( - "`git log {range}` failed: {}", - if stderr.is_empty() { - "no stderr".to_string() - } else { - stderr - }, - ))); - } - - String::from_utf8(output.stdout) - .map_err(|e| CliError::Generic(format!("`git log` output is not UTF-8: {e}"))) -} - -/// Parse the raw `git log` payload (NUL/RS separated) into -/// [`LocalCommit`] records. Exposed separately from [`read`] so -/// the format-handling can be unit-tested without spawning git. -pub fn parse(raw: &str) -> Result<Vec<LocalCommit>, CliError> { - let mut out = Vec::new(); - for record in raw.split('\x1e') { - let stripped = record.trim(); - if stripped.is_empty() { - continue; - } - let mut parts = stripped.splitn(4, '\x00'); - let commit_sha = parts - .next() - .ok_or_else(|| malformed_record(stripped))? - .trim() - .to_string(); - let title = parts - .next() - .ok_or_else(|| malformed_record(stripped))? - .trim() - .to_string(); - let message = parts - .next() - .ok_or_else(|| malformed_record(stripped))? - .trim() - .to_string(); - // `%N` is empty when no note exists on the configured ref. - // The field is still emitted by git so this never returns - // `None`; missing it means the record shape drifted, which - // is malformed. - let note = parts - .next() - .ok_or_else(|| malformed_record(stripped))? - .trim() - .to_string(); - - let change_id = change_id::extract_from_message(&message) - .ok_or_else(|| { - // Mirrors Python's `console_error` + INVALID_STATE - // exit. The CLI surface (`_internal stack-local-commits`) - // appends the helpful "did you run `mergify stack - // setup`?" hint in the binary wrapper. - CliError::InvalidState(format!( - "`Change-Id:` line is missing on commit {commit_sha}", - )) - })? - .to_string(); - let slug = slug::slugify_title(&title, &change_id); - - out.push(LocalCommit { - commit_sha, - title, - message, - change_id, - slug, - note, - }); - } - Ok(out) -} - -fn malformed_record(record: &str) -> CliError { - CliError::Generic(format!("Unexpected git log record format: {record:?}")) -} - -/// Type alias used by the binary wrapper when emitting the -/// `_internal stack-local-commits` JSON output. -pub type LocalCommits = Vec<LocalCommit>; - -/// Resolve `repo_dir` argument. Defaults to the process CWD when -/// the caller doesn't pass an explicit value — the typical -/// invocation is from `mergify stack <cmd>` running inside the -/// user's clone. -#[must_use] -pub fn resolve_repo_dir(arg: Option<PathBuf>) -> PathBuf { - arg.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn record(sha: &str, title: &str, body: &str) -> String { - // Most parse tests don't care about the note field; default - // to empty (the shape git emits for commits without a note - // on the configured ref). - record_with_note(sha, title, body, "") - } - - fn record_with_note(sha: &str, title: &str, body: &str, note: &str) -> String { - // Same wire shape git emits with our `--format` spec: NUL - // between fields, RS terminator after the note. - format!("{sha}\x00{title}\x00{body}\x00{note}\x1e") - } - - #[test] - fn parse_returns_commits_in_input_order() { - // `--reverse` means oldest first; we just trust git's - // ordering and check that we don't reorder it ourselves. - let raw = record( - "aaaa111111111111111111111111111111111111", - "first", - "body1\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ) + &record( - "bbbb222222222222222222222222222222222222", - "second", - "body2\n\nChange-Id: Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ); - let commits = parse(&raw).unwrap(); - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].title, "first"); - assert_eq!( - commits[0].commit_sha, - "aaaa111111111111111111111111111111111111" - ); - assert_eq!( - commits[0].change_id, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - assert_eq!(commits[1].title, "second"); - assert_eq!( - commits[1].change_id, - "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ); - } - - #[test] - fn parse_skips_empty_records_at_boundary() { - // A trailing `\x1e` after the last record produces an - // empty split fragment — mirror Python's `stripped: continue` - // skip. - let raw = record( - "aaaa111111111111111111111111111111111111", - "only", - "Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); - let commits = parse(&raw).unwrap(); - assert_eq!(commits.len(), 1); - } - - #[test] - fn parse_returns_empty_on_empty_input() { - // `git log` on a no-op range emits nothing. Stack walks - // should treat that as an empty result rather than an - // error. - assert!(parse("").unwrap().is_empty()); - assert!(parse("\x1e\x1e").unwrap().is_empty()); - } - - #[test] - fn parse_picks_last_trailer_when_message_has_multiple() { - // Amends sometimes append a new `Change-Id:` instead of - // replacing the old one; the most recent one is the live - // identity. - let raw = record( - "aaaa111111111111111111111111111111111111", - "amended", - "body\n\nChange-Id: I1111111111111111111111111111111111111111\nChange-Id: I2222222222222222222222222222222222222222", - ); - let commits = parse(&raw).unwrap(); - assert_eq!( - commits[0].change_id, - "I2222222222222222222222222222222222222222" - ); - } - - #[test] - fn parse_rejects_record_missing_a_field() { - // A three-field record (missing the note field) means our - // format spec drifted from what git emits — surfacing it - // loudly is more useful than silently dropping the - // commit. We expect 4 NUL-separated fields: SHA, title, - // body, note (note may be empty). - let raw = "aaaa111111111111111111111111111111111111\x00title\x00body\x1e"; - let err = parse(raw).unwrap_err(); - match err { - CliError::Generic(msg) => assert!(msg.contains("Unexpected git log record format")), - other => panic!("expected Generic, got: {other:?}"), - } - } - - #[test] - fn parse_extracts_note_when_present() { - // The walker piggy-backs `git notes --ref=refs/notes/mergify/stack` - // onto the same `git log` call by appending `%N` to the - // format spec. Confirm a commit with a note surfaces it - // verbatim on the `LocalCommit.note` field — that's how - // the revision-history rendering will receive it without - // a per-commit subprocess round-trip. - let raw = record_with_note( - "aaaa111111111111111111111111111111111111", - "amend", - "body\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "address review feedback", - ); - let commits = parse(&raw).unwrap(); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].note, "address review feedback"); - } - - #[test] - fn parse_returns_empty_note_when_absent() { - // Commits without a note still need a deterministic shape - // (the `%N` field is empty, but git still emits the NUL - // separator before the RS terminator). `record` defaults - // to empty note, exercising exactly this case. - let raw = record( - "aaaa111111111111111111111111111111111111", - "no-note", - "Change-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); - let commits = parse(&raw).unwrap(); - assert_eq!(commits.len(), 1); - assert_eq!(commits[0].note, ""); - } - - #[test] - fn parse_returns_invalid_state_when_change_id_missing() { - // The Python check exits with INVALID_STATE so the CLI - // can prompt the user to run `mergify stack setup`. We - // mirror the exit-code mapping so the user sees the same - // signal regardless of which side raised it. - let raw = record( - "aaaa111111111111111111111111111111111111", - "broken", - "body with no trailer", - ); - let err = parse(&raw).unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("Change-Id")); - assert!( - msg.contains("aaaa111111111111111111111111111111111111"), - "missing-Change-Id error must name the offending commit so the user can fix it: {msg}", - ); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn read_walks_a_real_repository() { - // End-to-end smoke that the `git -C <dir> log ...` - // invocation, the format spec, and our parser all agree - // on the wire shape — the unit `parse` tests can't catch - // a drift between our `--format` string and what git - // actually emits. - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path(); - let git = |args: &[&str]| { - let out = crate::test_env::isolated_git() - .arg("-C") - .arg(path) - .args(args) - .output() - .unwrap(); - assert!(out.status.success(), "git {args:?} failed: {out:?}"); - String::from_utf8(out.stdout).unwrap() - }; - git(&["init", "-q", "-b", "main"]); - git(&["config", "user.email", "test@example.com"]); - git(&["config", "user.name", "Tester"]); - git(&["commit", "--allow-empty", "-m", "base\n"]); - let base = git(&["rev-parse", "HEAD"]).trim().to_string(); - git(&[ - "commit", - "--allow-empty", - "-m", - "first\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ]); - git(&[ - "commit", - "--allow-empty", - "-m", - "second\n\nChange-Id: Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ]); - // Attach an amend-reason note to the second commit only, - // so the assertions below can verify both branches — - // `note == "address review feedback"` for the second, - // `note == ""` for the first. - let second_sha = git(&["rev-parse", "HEAD"]).trim().to_string(); - git(&[ - "notes", - &format!("--ref={STACK_NOTES_REF}"), - "add", - "-m", - "address review feedback", - &second_sha, - ]); - - let commits = read(path, &base, "HEAD").unwrap(); - assert_eq!(commits.len(), 2); - assert_eq!(commits[0].title, "first"); - assert_eq!(commits[1].title, "second"); - assert_eq!( - commits[0].change_id, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - // First commit has no note on the stack ref → empty - // string. Second has the note we just attached. - assert_eq!(commits[0].note, ""); - assert_eq!(commits[1].note, "address review feedback"); - } -} diff --git a/crates/mergify-stack/src/match_commit.rs b/crates/mergify-stack/src/match_commit.rs deleted file mode 100644 index 584dc787..00000000 --- a/crates/mergify-stack/src/match_commit.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Shared formatting for the prefix-matching errors every stack -//! subcommand emits. -//! -//! `drop`, `squash`, `fixup`, `edit`, `move`, `reword`, `reorder`, -//! and `note` each resolve a user-supplied `<COMMIT>` argument -//! against the stack by matching a SHA or Change-Id prefix. The -//! zero-match and ambiguous-match wording must be identical across -//! all of them and match the Python `reorder.py::match_commit` -//! output, so the formatting lives here once. -//! -//! Each command builds a slice of [`Candidate`] (a borrowed view -//! over its own per-commit type, all of which expose -//! `commit_sha` / `title` / `change_id`) and delegates. - -use mergify_core::CliError; - -/// A single ambiguous-match candidate, borrowed from the caller's -/// per-command commit type. `change_id` is empty when the commit -/// carries no `Change-Id` trailer; the formatter omits the -/// `(<change_id>)` suffix in that case. -pub struct Candidate<'a> { - pub commit_sha: &'a str, - pub title: &'a str, - pub change_id: &'a str, -} - -/// `field` is the human label for what the prefix matched against — -/// either `"SHA"` or `"Change-Id"`. -/// -/// Zero matches → [`CliError::StackNotFound`] (exit 3), matching -/// Python's `sys.exit(ExitCode.STACK_NOT_FOUND)`. -#[must_use] -pub fn not_found(field: &str, prefix: &str) -> CliError { - CliError::StackNotFound(format!( - "no commit found matching {field} prefix '{prefix}'" - )) -} - -/// Ambiguous match → [`CliError::InvalidState`] (exit 7), matching -/// Python's `sys.exit(ExitCode.INVALID_STATE)`. -/// -/// The message mirrors `reorder.py::match_commit`: -/// `ambiguous {field} prefix '{prefix}' matches {N} commits:` then -/// one line per candidate ` {sha[:12]} {title} ({change_id[:12]})` -/// (the `(...)` suffix omitted when the candidate has no Change-Id). -#[must_use] -pub fn ambiguous(field: &str, prefix: &str, candidates: &[Candidate<'_>]) -> CliError { - let listing = candidates - .iter() - .map(format_candidate) - .collect::<Vec<_>>() - .join("\n "); - CliError::InvalidState(format!( - "ambiguous {field} prefix '{prefix}' matches {n} commits:\n {listing}", - n = candidates.len(), - )) -} - -fn format_candidate(c: &Candidate<'_>) -> String { - let sha = &c.commit_sha[..c.commit_sha.len().min(12)]; - if c.change_id.is_empty() { - format!("{sha} {title}", title = c.title) - } else { - let cid = &c.change_id[..c.change_id.len().min(12)]; - format!("{sha} {title} ({cid})", title = c.title) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn cand<'a>(sha: &'a str, title: &'a str, change_id: &'a str) -> Candidate<'a> { - Candidate { - commit_sha: sha, - title, - change_id, - } - } - - #[test] - fn not_found_is_stack_not_found_with_field_and_prefix() { - match not_found("Change-Id", "Ibeef") { - CliError::StackNotFound(msg) => { - assert_eq!(msg, "no commit found matching Change-Id prefix 'Ibeef'"); - } - other => panic!("expected StackNotFound, got: {other:?}"), - } - } - - #[test] - fn ambiguous_is_invalid_state_with_count_and_header() { - let candidates = [ - cand( - "aaaaaaaaaaaa1111111111111111111111111111", - "first", - "Iaaaaaaaaaaaa1111111111111111111111111111", - ), - cand( - "bbbbbbbbbbbb2222222222222222222222222222", - "second", - "Ibbbbbbbbbbbb2222222222222222222222222222", - ), - ]; - match ambiguous("SHA", "ab", &candidates) { - CliError::InvalidState(msg) => { - // 12-char SHA + 12-char Change-Id (the `I` plus 11 - // hex chars). - assert_eq!( - msg, - "ambiguous SHA prefix 'ab' matches 2 commits:\n \ - aaaaaaaaaaaa first (Iaaaaaaaaaaa)\n \ - bbbbbbbbbbbb second (Ibbbbbbbbbbb)" - ); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn ambiguous_truncates_sha_and_change_id_to_twelve() { - let candidates = [cand( - "0123456789abcdef0123456789abcdef01234567", - "subject", - "I0123456789abcdef0123456789abcdef01234567", - )]; - match ambiguous("SHA", "01", &candidates) { - CliError::InvalidState(msg) => { - // 12-char SHA, 12-char Change-Id (the `I` plus 11 - // hex), not the 7-char SHA / no-Change-Id form the - // pre-fix code produced. - assert!( - msg.contains(" 0123456789ab subject (I0123456789a)"), - "got: {msg}" - ); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn ambiguous_omits_parens_when_no_change_id() { - let candidates = [cand("abcdef0123456789", "no cid", "")]; - match ambiguous("SHA", "abc", &candidates) { - CliError::InvalidState(msg) => { - assert!(msg.contains(" abcdef012345 no cid"), "got: {msg}"); - assert!(!msg.contains('('), "no parens expected: {msg}"); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn ambiguous_handles_short_sha_and_change_id_without_panicking() { - // A SHA/Change-Id shorter than 12 chars must clamp rather - // than slice out of bounds. - let candidates = [cand("abc", "short", "Ixyz")]; - match ambiguous("Change-Id", "Ix", &candidates) { - CliError::InvalidState(msg) => { - assert!(msg.contains(" abc short (Ixyz)"), "got: {msg}"); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/native_stack.rs b/crates/mergify-stack/src/native_stack.rs deleted file mode 100644 index bd6ee63a..00000000 --- a/crates/mergify-stack/src/native_stack.rs +++ /dev/null @@ -1,612 +0,0 @@ -//! Registration of a pushed stack with GitHub's **native** Stacks -//! API — on by default for `stack push`, opt out with -//! `--no-github-native`. -//! -//! Native membership is *additive* in identity: Change-Id, the branch -//! layout and the revision history stay ours. All this module does is -//! tell GitHub "these PRs, in this order, are one stack" so its UI and -//! its stack-aware merge path see what our stack comment already -//! describes. -//! -//! It is not additive in *ordering*, and deliberately so. A registered -//! stack holds the dependency between two pull requests, so -//! [`crate::commands::push`] stops writing the `Depends-On:` header -//! that used to hold it: one edge, one place it is recorded, and the -//! one users read is no longer the redundant copy. Because -//! [`register`] is allowed to do nothing (below), the removal is keyed -//! off the registration having actually happened — a push that -//! degrades keeps the header and Mergify keeps ordering the stack. -//! -//! # What a live registration does and does not block -//! -//! Registration is not inert — it locks *one* thing. Measured against -//! the live API (2026-08-05); endpoint paths below are written relative -//! to `/repos/{owner}/{repo}`, as elsewhere in this crate: -//! -//! - `PATCH /pulls/{n}` fails with 422 *"Cannot change the base branch -//! because the pull request is part of a stack"* whenever the `base` -//! key is present **at all**, including when it is set to the value -//! the PR already has. That is the whole lock, and it is why -//! [`crate::pr_upsert::create_or_update_pr`] sends `base` only when -//! the PR is really being retargeted. The same PATCH without the key -//! — new `title`, new `body`, even a `head` that does not exist — -//! succeeds while stacked, and the stack survives the force-push of -//! its members' branches. So a push that only refreshes commits -//! needs no dissolve at all. -//! - The 422 is **not atomic**: a body carrying `base` *and* `title` -//! applies the title and rejects the base. Firing one blind and -//! treating the error as "nothing happened" is not an option. -//! - Orphan teardown ([`crate::pr_upsert::delete_orphan_branch`]) -//! deletes a dropped change's head branch, and GitHub closes not only -//! that PR but every PR still based on the deleted branch. Push is -//! safe today only because step 9 retargets the survivors *before* -//! step 11 deletes the branch — a retarget the lock above would -//! block. A PR closed this way cannot be reopened (its base branch is -//! gone) or retargeted (it is closed): it is permanently lost. And -//! the dropped PR, closed but unmerged, stays a member of the stack, -//! which then no longer describes anything real. -//! - `POST /stacks` on PRs that are already in an open stack fails with -//! 422 *"are already part of a stack"* — a fresh POST does not -//! replace an existing registration, so *re-forming* requires an -//! unstack first. -//! -//! # The three shapes of a push -//! -//! Hence [`crate::commands::push`] dissolves the stack for exactly the -//! pushes that need it, and nothing else: -//! -//! | push | stacks requests | -//! |---|---| -//! | refresh commits (amend, reword, force-push) | **none** | -//! | append a change on top | one [`append`] | -//! | retarget a PR, or tear down an orphan | [`unstack`] up front, [`register`] at the end | -//! -//! The fence, when it is needed, is the whole mutation stage rather -//! than one call site: the retarget can come from -//! [`crate::pr_upsert::neutralize_stale_bases`] *or* from the upsert -//! itself, and the orphan teardown at the end depends on the retarget -//! having landed. In between, the flow is byte-for-byte the flow that -//! runs when nothing gets registered, which is what makes the failure -//! mode benign — an interrupted push leaves the stack merely -//! unregistered, i.e. exactly the pre-registration behaviour. -//! -//! # Failure policy — deliberately asymmetric -//! -//! - [`register`] and [`append`] **never fail the push.** A repo -//! without the feature, an old GHES, a chain with a hole in it, or a -//! stack that has shrunk below the 2-PR floor all just leave the PRs -//! unregistered. That is the documented graceful degradation: the -//! user gets today's stack. -//! - [`unstack`] failing **is** a hard error *before* the mutations. It -//! is the one case where carrying on produces the unrecoverable state -//! above, so the push stops before it can touch a single PR. (After -//! the mutations, when it is only used to rebuild a stale -//! registration, a failure is harmless and the caller swallows it.) - -use mergify_core::{CliError, HttpClient}; -use serde::Serialize; -use serde_json::Value; - -/// Fewest PRs GitHub will accept in a stack. A `POST /stacks` with one -/// pull request is rejected with 422 *"2 items required; only 1 was -/// supplied"*, so a single-change stack stays a plain PR — and a -/// 2-member stack that loses a member dissolves rather than re-forming. -const MIN_STACK_SIZE: usize = 2; - -#[derive(Serialize)] -struct CreateStack<'a> { - /// Bottom-to-top. GitHub requires JSON integers here; strings are - /// rejected with a schema 422. - pull_requests: &'a [u64], -} - -/// The stack number these PR payloads say they currently belong to. -/// -/// GitHub puts a `stack` object on the PR payload on the **default** -/// API version, and [`crate::remote_changes`] already fetches every -/// PR in full — so the current registration costs no extra request. -/// The `number` (not the `id`) is the path key for [`unstack`]. -/// -/// Members of one stack all report the same number; the first one -/// found wins. -pub fn registered_number<'a>(pulls: impl IntoIterator<Item = &'a Value>) -> Option<u64> { - pulls - .into_iter() - .find_map(|pull| pull.pointer("/stack/number").and_then(Value::as_u64)) -} - -/// Whether `pull` is already a member of stack `number`, read from the -/// same `stack` object [`registered_number`] uses. -pub fn is_member_of(pull: &Value, number: u64) -> bool { - pull.pointer("/stack/number").and_then(Value::as_u64) == Some(number) -} - -/// Dissolve stack `number` so the PRs can be mutated again. -/// -/// All-or-nothing by design — the endpoint takes no body and there is -/// no way to drop a single member. A 404 means the stack is already -/// gone (a stale number, or a concurrent unstack) and counts as -/// success: the postcondition "these PRs are not in a stack" holds -/// either way. -/// -/// # Errors -/// -/// Any other failure is returned. Unlike [`register`], a failure here -/// must stop a push that has not started mutating yet: the caller is -/// about to issue the `PATCH`es that a live registration turns into an -/// unrecoverable teardown (see the module docs). The same call is also -/// used *after* the mutations to rebuild a registration [`append`] -/// could not extend — there the postcondition is only cosmetic, and the -/// caller ignores the error. -pub async fn unstack( - client: &HttpClient, - user: &str, - repo: &str, - number: u64, -) -> Result<(), CliError> { - let path = format!("/repos/{user}/{repo}/stacks/{number}/unstack"); - match client.post_empty_if_exists(&path).await { - Ok(()) => { - tracing::debug!(stack = number, "dissolved GitHub stack"); - Ok(()) - } - Err(e) => Err(CliError::wrap( - format!( - "could not dissolve GitHub stack #{number} before updating the pull requests \ - (GitHub refuses to change a pull request's base branch while it is stacked). \ - Retry, or unstack it by hand and push again", - ), - e, - )), - } -} - -/// Register `pulls` (bottom-to-top) as one native stack, best effort. -/// -/// Returns the new stack number, or `None` when the stack was not -/// registered — which is a routine, non-failing outcome: -/// -/// - fewer than [`MIN_STACK_SIZE`] open PRs (a one-change stack is a -/// plain PR, and a shrunken 2-PR stack dissolves); -/// - the repository or GitHub deployment has no Stacks API (old GHES, -/// feature not enabled) — 404; -/// - the chain has a hole in it, e.g. under -/// `--only-update-existing-pulls` — 422. -/// -/// None of these are worth failing a push that has already created, -/// updated and linked every PR correctly, so the error is logged at -/// debug and swallowed. The caller reports the outcome as a progress -/// row rather than an error. -pub async fn register(client: &HttpClient, user: &str, repo: &str, pulls: &[u64]) -> Option<u64> { - if pulls.len() < MIN_STACK_SIZE { - tracing::debug!( - count = pulls.len(), - "not registering a GitHub stack: below the 2 pull request minimum" - ); - return None; - } - let path = format!("/repos/{user}/{repo}/stacks"); - let body = CreateStack { - pull_requests: pulls, - }; - match client.post::<_, Value>(&path, &body).await { - Ok(stack) => { - let number = stack.get("number").and_then(Value::as_u64); - tracing::debug!(?number, ?pulls, "registered GitHub stack"); - number - } - Err(e) => { - tracing::debug!( - error = %e, - "GitHub stack registration unavailable; leaving the pull requests unstacked" - ); - None - } - } -} - -/// Append `pulls` (bottom-to-top) on top of registered stack `number`, -/// best effort. -/// -/// The incremental counterpart to [`register`]: `POST /stacks/{n}/add` -/// keeps the stack — its number, its webhooks, its existing members' -/// registration — and only extends it, so pushing a new change on top -/// of a stack costs one request instead of a dissolve plus a full -/// re-registration. -/// -/// GitHub requires the first appended PR's `base` ref to be the current -/// top member's `head` ref; a list that does not chain on is rejected -/// with 422 *"Pull requests must form a stack, where each PR's base ref -/// is the previous PR's head ref"*. Everything else the API cannot do -/// — inserting in the middle, reordering, dropping a member — has no -/// endpoint at all and goes through [`unstack`] + [`register`]. -/// -/// Returns `false` when the append did not happen, on the same terms as -/// [`register`]: never an error, always a caller-visible outcome. The -/// caller's remedy is to rebuild the stack from scratch. -pub async fn append( - client: &HttpClient, - user: &str, - repo: &str, - number: u64, - pulls: &[u64], -) -> bool { - if pulls.is_empty() { - return true; - } - let path = format!("/repos/{user}/{repo}/stacks/{number}/add"); - let body = CreateStack { - pull_requests: pulls, - }; - match client.post::<_, Value>(&path, &body).await { - Ok(_) => { - tracing::debug!(stack = number, ?pulls, "appended to GitHub stack"); - true - } - Err(e) => { - tracing::debug!( - error = %e, - stack = number, - "could not append to the GitHub stack; rebuilding it instead" - ); - false - } - } -} - -/// One open pull request of the stack we are about to describe, in -/// stack order, paired with whether GitHub's registration already -/// holds it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Member { - pub number: u64, - pub registered: bool, -} - -/// The members GitHub is missing, when they are exactly a suffix of -/// `members` — the only difference [`append`] can express. -/// -/// `Some([])` means the registration already describes the stack and -/// the push should issue no request at all. `None` means the difference -/// is something else — a new change landed *under* an existing one, or -/// a registered member is no longer in the stack — and the caller has -/// to dissolve and re-register. -/// -/// Merged members need no special case: they are not in `members` at -/// all (GitHub keeps them in the stack and infers the merged prefix -/// itself), so a stack whose bottom just merged still reads as "already -/// correct". -#[must_use] -pub fn appendable_tail(members: &[Member]) -> Option<Vec<u64>> { - let split = members.iter().take_while(|m| m.registered).count(); - if members[split..].iter().any(|m| m.registered) { - // A registered member sits above an unregistered one: the new - // change went into the middle, not on top. - return None; - } - Some(members[split..].iter().map(|m| m.number).collect()) -} - -/// PR number of `pull` when it is an open, unmerged pull request — -/// i.e. one that belongs in a stack registration. -/// -/// Merged members are excluded deliberately: a fresh `POST /stacks` -/// describes the stack that is still in flight, and GitHub derives the -/// merged prefix itself. -pub fn open_pull_number(pull: Option<&Value>) -> Option<u64> { - let pull = pull?; - if pull.get("merged_at").is_some_and(|v| !v.is_null()) { - return None; - } - if pull.get("state").and_then(Value::as_str) == Some("closed") { - return None; - } - pull.get("number").and_then(Value::as_u64) -} - -#[cfg(test)] -mod tests { - use super::*; - use mergify_core::{ApiFlavor, HttpClient}; - use serde_json::json; - use url::Url; - use wiremock::matchers::{body_json, method, path as wm_path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "token", - ApiFlavor::GitHub, - ) - .unwrap() - } - - #[test] - fn registered_number_reads_the_stack_object_off_a_pull_payload() { - // Membership rides along on the payload `remote_changes` - // already fetches — pinning the pointer keeps us from - // reintroducing a `GET /stacks` round-trip. - let pulls = [ - json!({"number": 1, "stack": null}), - json!({"number": 2, "stack": {"id": 162_170, "number": 7, "position": 1}}), - ]; - // The path key is `number` (7), not `id` (162170). - assert_eq!(registered_number(pulls.iter()), Some(7)); - } - - #[test] - fn registered_number_is_none_when_nothing_is_stacked() { - let pulls = [json!({"number": 1, "stack": null}), json!({"number": 2})]; - assert_eq!(registered_number(pulls.iter()), None); - } - - #[test] - fn open_pull_number_selects_only_live_members() { - assert_eq!( - open_pull_number(Some( - &json!({"number": 5, "state": "open", "merged_at": null}) - )), - Some(5) - ); - // Merged members are GitHub's to infer, not ours to re-send. - assert_eq!( - open_pull_number(Some( - &json!({"number": 5, "state": "closed", "merged_at": "2026-01-01T00:00:00Z"}) - )), - None - ); - assert_eq!( - open_pull_number(Some( - &json!({"number": 5, "state": "closed", "merged_at": null}) - )), - None - ); - assert_eq!(open_pull_number(None), None); - } - - #[tokio::test] - async fn register_posts_members_bottom_to_top_as_integers() { - // GitHub rejects stringified numbers with a schema 422, and - // order is the stack order — both are load-bearing, so assert - // the exact body. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks")) - .and(body_json(json!({"pull_requests": [9, 10, 11]}))) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({"number": 12}))) - .expect(1) - .mount(&server) - .await; - - let got = register(&client(&server), "o", "r", &[9, 10, 11]).await; - assert_eq!(got, Some(12)); - } - - #[tokio::test] - async fn register_below_the_floor_issues_no_request() { - // A one-change stack is a plain PR. Wiremock with no mounted - // mock 404s any call, so `Some(_)` here would mean we made one. - let server = MockServer::start().await; - assert_eq!(register(&client(&server), "o", "r", &[9]).await, None); - assert_eq!(register(&client(&server), "o", "r", &[]).await, None); - assert!(server.received_requests().await.unwrap().is_empty()); - } - - #[tokio::test] - async fn register_degrades_silently_when_the_api_is_unavailable() { - // 404 is an old GHES or a repo without the feature; 422 is a - // chain with a hole in it. Neither may fail a push whose PRs - // are already correct. - for status in [404, 422, 403] { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks")) - .respond_with(ResponseTemplate::new(status).set_body_string("nope")) - .mount(&server) - .await; - assert_eq!( - register(&client(&server), "o", "r", &[9, 10]).await, - None, - "status {status} must degrade, not fail" - ); - } - } - - #[tokio::test] - async fn register_tolerates_a_response_without_a_number() { - // A proxy or a future API version returning a shape we don't - // recognise still means "registered"; we just have nothing to - // display. Must not panic or error. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({}))) - .mount(&server) - .await; - assert_eq!(register(&client(&server), "o", "r", &[9, 10]).await, None); - } - - #[test] - fn is_member_of_matches_only_the_stack_we_read() { - let pull = json!({"number": 1, "stack": {"number": 7, "position": 1}}); - assert!(is_member_of(&pull, 7)); - assert!(!is_member_of(&pull, 8)); - assert!(!is_member_of(&json!({"number": 1, "stack": null}), 7)); - } - - #[test] - fn appendable_tail_is_empty_when_the_registration_is_already_right() { - // The routine push: same members, same order. Nothing to send — - // this is the case that used to cost an unstack + a re-register - // on every single push. - let members = [ - Member { - number: 101, - registered: true, - }, - Member { - number: 102, - registered: true, - }, - ]; - assert_eq!(appendable_tail(&members), Some(vec![])); - } - - #[test] - fn appendable_tail_returns_the_new_changes_on_top() { - let members = [ - Member { - number: 101, - registered: true, - }, - Member { - number: 102, - registered: true, - }, - Member { - number: 103, - registered: false, - }, - Member { - number: 104, - registered: false, - }, - ]; - assert_eq!(appendable_tail(&members), Some(vec![103, 104])); - } - - #[test] - fn appendable_tail_refuses_a_change_inserted_under_a_member() { - // A new change in the middle moves the bases of everything - // above it — there is no endpoint for that, so the caller has - // to dissolve and re-register. - let members = [ - Member { - number: 101, - registered: true, - }, - Member { - number: 103, - registered: false, - }, - Member { - number: 102, - registered: true, - }, - ]; - assert_eq!(appendable_tail(&members), None); - } - - #[test] - fn appendable_tail_of_an_all_new_stack_is_everything() { - let members = [ - Member { - number: 101, - registered: false, - }, - Member { - number: 102, - registered: false, - }, - ]; - assert_eq!(appendable_tail(&members), Some(vec![101, 102])); - assert_eq!(appendable_tail(&[]), Some(vec![])); - } - - #[tokio::test] - async fn append_posts_the_new_members_to_the_add_endpoint() { - // Same body shape as `register` — integers, bottom-to-top — but - // onto the existing stack, so its number and its members' - // registration survive. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks/7/add")) - .and(body_json(json!({"pull_requests": [11]}))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"number": 7}))) - .expect(1) - .mount(&server) - .await; - - assert!(append(&client(&server), "o", "r", 7, &[11]).await); - } - - #[tokio::test] - async fn append_of_nothing_issues_no_request() { - // The most common push of all: members unchanged. Wiremock - // would 404 any call, and `true` here means we made none. - let server = MockServer::start().await; - assert!(append(&client(&server), "o", "r", 7, &[]).await); - assert!(server.received_requests().await.unwrap().is_empty()); - } - - #[tokio::test] - async fn append_reports_failure_instead_of_erroring() { - // 422 (the appended PR doesn't chain onto the current top), 404 - // (someone dissolved the stack meanwhile). Both mean "rebuild - // it", never "fail the push". - for status in [404, 422, 409] { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks/7/add")) - .respond_with(ResponseTemplate::new(status).set_body_string("nope")) - .mount(&server) - .await; - assert!( - !append(&client(&server), "o", "r", 7, &[11]).await, - "status {status} must report failure, not error" - ); - } - } - - #[tokio::test] - async fn unstack_posts_to_the_stack_number_with_no_body() { - // The endpoint takes no request body and returns an empty - // 204 — decoding it as JSON would fail. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks/7/unstack")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - unstack(&client(&server), "o", "r", 7).await.unwrap(); - } - - #[tokio::test] - async fn unstack_treats_404_as_already_dissolved() { - // A stale stack number (someone unstacked by hand between our - // fetch and now) satisfies the postcondition, so it must not - // block the push. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks/7/unstack")) - .respond_with(ResponseTemplate::new(404).set_body_string("Not Found")) - .mount(&server) - .await; - - unstack(&client(&server), "o", "r", 7).await.unwrap(); - } - - #[tokio::test] - async fn unstack_failure_is_fatal_and_explains_itself() { - // The one asymmetry with `register`: carrying on past a failed - // unstack is what permanently closes a surviving PR, so this - // must stop the push before it mutates anything. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/stacks/7/unstack")) - .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) - .mount(&server) - .await; - - let err = unstack(&client(&server), "o", "r", 7).await.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("could not dissolve GitHub stack #7"), - "got: {msg}" - ); - assert!(msg.contains("unstack it by hand"), "got: {msg}"); - } -} diff --git a/crates/mergify-stack/src/notes_push.rs b/crates/mergify-stack/src/notes_push.rs deleted file mode 100644 index 213e4839..00000000 --- a/crates/mergify-stack/src/notes_push.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! Git-level operations the `stack push` orchestrator runs before -//! and during the actual `git push`: -//! -//! - [`fetch_notes_ref`] — learn the remote's current state of -//! `refs/notes/mergify/stack` before the push, returning a -//! [`NotesLease`] that anchors the notes push's -//! `--force-with-lease` (so a concurrent notes push landing -//! between our fetch and our push is rejected instead of -//! silently clobbered). Unifies the "first push" (ref missing -//! both locally and remotely) and "follow-up push" (local -//! notes from `stack note`, remote notes from prior pushes — -//! merge them union-style) paths into one call. -//! - [`push_branches`] — the actual `git push --atomic -//! --force-with-lease …` that lands every create/update from -//! the planned changes plus the notes ref in one shot. Because -//! the notes ref rides along in the same `--atomic` push as the -//! branches, a stale notes lease fails the *whole* push — -//! branches included. That's intentional, not a rough edge: the -//! caller's retry re-fetches (getting a fresh [`NotesLease`]) -//! and union-merges before trying again, so nothing is lost. -//! -//! Ported from `mergify_cli/stack/push.py` — -//! `_merge_remote_notes`, `fetch_notes_ref`, `push_branches`. - -use std::path::Path; - -use crate::git::{git_cmd, run_git_capture, run_git_silent}; - -use mergify_core::CliError; - -use crate::local_commits::STACK_NOTES_REF; - -/// Marker env var: the pre-push hook reads this to know it's -/// being invoked by `mergify stack push` (vs a user's -/// `git push`) and skip its own logic accordingly. Set across -/// the duration of [`push_branches`] only. -const PUSH_ENV_MARKER: &str = "MERGIFY_STACK_PUSH"; - -/// Temporary fetch ref used by [`fetch_notes_ref`] to land the -/// remote notes before merging them union-style into the local -/// `refs/notes/mergify/stack`. -const NOTES_INCOMING_REF: &str = "refs/notes/mergify/stack-incoming"; - -/// One PR's worth of refspec input for [`push_branches`]. Built -/// from the orchestrator's planned-changes list — only the -/// `Create`/`Update` entries make it in. -#[derive(Debug, Clone)] -pub struct PushEntry { - /// SHA of the local commit being pushed. - pub commit_sha: String, - /// Remote branch name `mergify stack` chose for this PR. - pub dest_branch: String, - /// For `Update`: the remote PR head SHA that `--force-with-lease` - /// anchors on (so a concurrent push to the same PR fails fast - /// instead of clobbering work). `None` for `Create`, which - /// uses an empty lease meaning "ref must not exist." - pub pull_head_sha: Option<String>, -} - -/// What we learned about the remote `refs/notes/mergify/stack` -/// while fetching — the lease anchor for the notes push. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum NotesLease { - /// Remote ref seen at this SHA: lease the push on it, so a - /// concurrent notes push between our fetch and our push is - /// rejected instead of clobbered. - RemoteAt(String), - /// Remote ref doesn't exist: lease on "ref must not exist". - RemoteAbsent, - /// Transient failure talking to the remote — its state is - /// unknown, so no safe lease exists. The notes refspec is - /// left out of the push entirely; branches still land and - /// the notes catch up on the next push. - Unknown, -} - -/// Learn the remote's current state of `refs/notes/mergify/stack` -/// so the next push can lease its own notes push on it — see -/// [`NotesLease`]. -/// -/// When the ref already exists locally (e.g. from a prior -/// `stack note`), we fetch the remote's copy into a temp ref, -/// capture its SHA as the lease anchor *before* touching anything -/// else, then union-merge it into the local ref so unpushed local -/// notes survive. The merge itself is best-effort — its failure is -/// swallowed so a transient remote hiccup doesn't block the -/// surrounding push — but a lease was already captured either way. -/// -/// When the ref is missing locally, we fetch it directly: success -/// captures the newly-landed ref's SHA as `RemoteAt`; `couldn't -/// find remote ref` means "first push of the stack", which maps to -/// `RemoteAbsent`; any other failure propagates as `Err` so the -/// user sees what went wrong (network, auth, …) — unchanged from -/// before this function returned a lease type. -pub fn fetch_notes_ref(repo_dir: Option<&Path>, remote: &str) -> Result<NotesLease, CliError> { - // Ref exists locally → fetch-and-merge the remote in, capturing - // whatever lease that path can construct. - if run_git_silent(repo_dir, &["rev-parse", "--verify", STACK_NOTES_REF]).is_ok() { - return Ok(fetch_and_merge_remote_notes(repo_dir, remote)); - } - - // Ref missing locally → try to fetch it. `couldn't find - // remote ref` means "first push of the stack", which is a - // benign `RemoteAbsent`. Any other failure propagates so the - // user sees what went wrong (network, auth, …). - let refspec = format!("{STACK_NOTES_REF}:{STACK_NOTES_REF}"); - let output = git_cmd(repo_dir) - .args(["fetch", remote, "--no-write-fetch-head", &refspec]) - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git fetch`: {e}")))?; - if output.status.success() { - // Capture immediately — nothing else writes to the ref on - // this path, but the lease must reflect exactly what the - // fetch just landed. - let sha = run_git_capture(repo_dir, &["rev-parse", "--verify", STACK_NOTES_REF])?; - return Ok(NotesLease::RemoteAt(sha)); - } - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("couldn't find remote ref") { - return Ok(NotesLease::RemoteAbsent); - } - Err(CliError::Generic(format!( - "`git fetch {remote} {refspec}` failed: {}", - stderr.trim() - ))) -} - -/// The "local notes ref already exists" path of [`fetch_notes_ref`]: -/// fetch the remote's copy into [`NOTES_INCOMING_REF`], capture its -/// SHA as the lease anchor, then union-merge it into the local ref. -/// -/// Never errors — a transient fetch failure (network, auth, remote -/// gone) yields `NotesLease::Unknown` rather than blocking the -/// surrounding push, which only needs *a* branch push to succeed. -fn fetch_and_merge_remote_notes(repo_dir: Option<&Path>, remote: &str) -> NotesLease { - let refspec = format!("+{STACK_NOTES_REF}:{NOTES_INCOMING_REF}"); - let Ok(output) = git_cmd(repo_dir) - .args(["fetch", remote, "--no-write-fetch-head", &refspec]) - .output() - else { - return NotesLease::Unknown; - }; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return if stderr.contains("couldn't find remote ref") { - NotesLease::RemoteAbsent - } else { - NotesLease::Unknown - }; - } - - // Capture the remote SHA *before* the union-merge below writes - // to the local ref — the lease must anchor on exactly what the - // remote had at fetch time. - let lease = run_git_capture(repo_dir, &["rev-parse", "--verify", NOTES_INCOMING_REF]) - .map_or(NotesLease::Unknown, NotesLease::RemoteAt); - - // `notes merge --strategy=union` keeps both sides verbatim - // when they diverge. Stack notes are either plain amend - // reasons or full history notes keyed by SHA; a union may - // concatenate two divergent history notes, which - // `revision_note::parse` tolerates (it keeps the longest - // marker payload) and the next push rewrites cleanly. - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - let _ = run_git_silent( - repo_dir, - &[ - "notes", - ¬es_ref_arg, - "merge", - "--strategy=union", - NOTES_INCOMING_REF, - ], - ); - // Cleanup the tmp ref regardless of merge outcome — Python - // wraps this in try/finally so we can't leave a dangling - // `refs/notes/mergify/stack-incoming` behind. - let _ = run_git_silent(repo_dir, &["update-ref", "-d", NOTES_INCOMING_REF]); - - lease -} - -/// `git push --atomic [--no-verify] --force-with-lease=… …` — -/// land every Create/Update entry plus the notes ref in one -/// atomic push. -/// -/// `notes_lease` is the [`NotesLease`] [`fetch_notes_ref`] returned -/// during pre-flight: -/// - `RemoteAt(sha)` leases the notes refspec on that SHA. -/// - `RemoteAbsent` leases on the empty string ("ref must not -/// exist"), protecting the create race too. -/// - `Unknown` (the remote's state couldn't be determined) drops -/// the notes lease *and* the notes refspec from this push -/// entirely, rather than force-pushing blind — branches still -/// land, and the notes catch up on the next push. -/// -/// `MERGIFY_STACK_PUSH=1` is exported across the push call so -/// the pre-push hook can tell user pushes apart from CLI pushes -/// and skip its own logic. Cleared on the way out so the env -/// doesn't leak to whatever the orchestrator does next. -pub fn push_branches( - repo_dir: Option<&Path>, - remote: &str, - entries: &[PushEntry], - no_verify: bool, - notes_lease: &NotesLease, -) -> Result<(), CliError> { - if entries.is_empty() { - return Ok(()); - } - - let mut args: Vec<String> = vec!["push".into(), "--atomic".into()]; - if no_verify { - args.push("--no-verify".into()); - } - - // One `--force-with-lease` per entry. For Update we lease on - // the previous PR head SHA so a concurrent push to the same - // PR fails fast instead of clobbering work. For Create we - // lease on the empty string, which means "the ref must not - // exist" — same protection against a Create-vs-Create race. - for entry in entries { - let lease = match &entry.pull_head_sha { - Some(sha) => format!("--force-with-lease=refs/heads/{}:{sha}", entry.dest_branch), - None => format!("--force-with-lease=refs/heads/{}:", entry.dest_branch), - }; - args.push(lease); - } - - // Notes ref lease + refspec only when the local notes ref - // exists — there's nothing to push otherwise. When the lease - // is `Unknown` (remote state couldn't be determined at fetch - // time), skip the notes refspec entirely rather than force- - // push blind: branches still land, and the notes catch up on - // the next push. - let notes_local_sha = - run_git_capture(repo_dir, &["rev-parse", "--verify", STACK_NOTES_REF]).ok(); - let mut include_notes_refspec = false; - if notes_local_sha.is_some() { - match notes_lease { - NotesLease::RemoteAt(sha) => { - args.push(format!("--force-with-lease={STACK_NOTES_REF}:{sha}")); - include_notes_refspec = true; - } - NotesLease::RemoteAbsent => { - args.push(format!("--force-with-lease={STACK_NOTES_REF}:")); - include_notes_refspec = true; - } - NotesLease::Unknown => {} - } - } - - args.push(remote.to_string()); - for entry in entries { - args.push(format!( - "{}:refs/heads/{}", - entry.commit_sha, entry.dest_branch, - )); - } - if include_notes_refspec { - // No leading `+`: the refspec must go through the - // `--force-with-lease` above rather than bypass it. A `+` - // here would make git ignore the lease and force-push - // unconditionally, which is exactly the bug this fixes. - args.push(format!("{STACK_NOTES_REF}:{STACK_NOTES_REF}")); - } - - let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); - - // Scope the env-var to the push so it doesn't leak to - // whatever the orchestrator does next (logging, comment - // updates, …). Always cleanup, even on failure. - let mut cmd = git_cmd(repo_dir); - cmd.args(&arg_refs).env(PUSH_ENV_MARKER, "1"); - // Capture stdio rather than inherit it: `git push` narrates the - // branch creation, `remote:` ruleset-bypass notices, and the - // "create a pull request by visiting …" hint, all of which are - // noise next to the orchestrator's own plan/created summary. - // Keep it for the failure path only. Mirrors `run_git_silent` - // and the Python `utils.git` wrapper. - let output = cmd - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git push`: {e}")))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr = stderr.trim(); - return Err(CliError::Generic(if stderr.is_empty() { - format!("`git push` exited {}", output.status) - } else { - format!("`git push` exited {}:\n{stderr}", output.status) - })); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = TempDir::new().unwrap(); - let path = dir.path(); - run(path, &["init", "-q", "-b", "main"]); - run(path, &["config", "user.email", "t@e.com"]); - run(path, &["config", "user.name", "t"]); - run(path, &["config", "commit.gpgsign", "false"]); - dir - } - - fn init_bare_repo() -> TempDir { - let dir = TempDir::new().unwrap(); - let path = dir.path(); - run(path, &["init", "-q", "--bare", "-b", "main"]); - dir - } - - fn run(path: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .unwrap(); - assert!(status.success(), "git {args:?} failed"); - } - - fn rev_parse(path: &Path, refname: &str) -> Option<String> { - run_git_capture(Some(path), &["rev-parse", "--verify", refname]).ok() - } - - fn seed_commit(path: &Path, name: &str, body: &str, msg: &str) -> String { - std::fs::write(path.join(name), body).unwrap(); - run(path, &["add", name]); - run(path, &["commit", "-q", "-m", msg]); - rev_parse(path, "HEAD").unwrap() - } - - #[test] - fn fetch_notes_ref_returns_remote_absent_on_first_push_when_neither_side_has_ref() { - // Bare remote with no notes ref + local repo with no - // notes ref → first-push path. Must return `RemoteAbsent` - // without erroring so the orchestrator drops the - // `--force-with-lease` on the notes ref. - let bare = init_bare_repo(); - let local = init_repo(); - seed_commit(local.path(), "x", "x\n", "x"); - run( - local.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - - let got = fetch_notes_ref(Some(local.path()), "origin").unwrap(); - assert_eq!(got, NotesLease::RemoteAbsent); - // Local ref must still not exist (we didn't fabricate one). - assert!(rev_parse(local.path(), STACK_NOTES_REF).is_none()); - } - - #[test] - fn fetch_notes_ref_returns_remote_absent_when_local_ref_already_exists() { - // Local `stack note` already created the ref, but the - // remote has nothing — the fetch-and-merge path's fetch - // fails with "couldn't find remote ref", which maps to - // `RemoteAbsent` rather than erroring. - let bare = init_bare_repo(); - let local = init_repo(); - let sha = seed_commit(local.path(), "x", "x\n", "x"); - run( - local.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - // Attach a note locally. - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - run( - local.path(), - &["notes", ¬es_ref_arg, "add", "-m", "why", &sha], - ); - assert!(rev_parse(local.path(), STACK_NOTES_REF).is_some()); - - let got = fetch_notes_ref(Some(local.path()), "origin").unwrap(); - assert_eq!(got, NotesLease::RemoteAbsent); - } - - #[test] - fn fetch_notes_ref_returns_remote_at_when_only_remote_has_ref() { - // Remote has notes (someone else pushed first); local - // is empty. The fetch lands the ref locally and the - // lease anchors on the remote's SHA. - let bare = init_bare_repo(); - - // Seed the bare with a commit + a note, then push them. - let producer = init_repo(); - let sha = seed_commit(producer.path(), "x", "x\n", "x"); - run( - producer.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - run( - producer.path(), - &["notes", ¬es_ref_arg, "add", "-m", "why", &sha], - ); - let notes_refspec = format!("+{STACK_NOTES_REF}:{STACK_NOTES_REF}"); - run(producer.path(), &["push", "origin", "main", ¬es_refspec]); - let remote_sha = rev_parse(bare.path(), STACK_NOTES_REF).unwrap(); - - // Fresh consumer with no notes ref locally. - let local = init_repo(); - seed_commit(local.path(), "y", "y\n", "y"); - run( - local.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - assert!(rev_parse(local.path(), STACK_NOTES_REF).is_none()); - - let got = fetch_notes_ref(Some(local.path()), "origin").unwrap(); - assert_eq!(got, NotesLease::RemoteAt(remote_sha)); - // The fetch lands the notes ref locally. - assert!(rev_parse(local.path(), STACK_NOTES_REF).is_some()); - } - - #[test] - fn fetch_notes_ref_returns_unknown_when_remote_unreachable() { - // Local ref exists (so the fetch-and-merge path runs), but - // the remote URL doesn't resolve to anything — the fetch - // fails for a reason other than "couldn't find remote - // ref", so no safe lease can be constructed. - let local = init_repo(); - let sha = seed_commit(local.path(), "x", "x\n", "x"); - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - run( - local.path(), - &["notes", ¬es_ref_arg, "add", "-m", "why", &sha], - ); - let bogus_remote = local.path().join("no-such-remote"); - run( - local.path(), - &["remote", "add", "origin", bogus_remote.to_str().unwrap()], - ); - - let got = fetch_notes_ref(Some(local.path()), "origin").unwrap(); - assert_eq!(got, NotesLease::Unknown); - } - - #[test] - fn push_branches_is_noop_when_entries_empty() { - // No entries → no git push call. Bare remote isn't even - // queried (and if it were the result would still be Ok - // since git push with no refspecs is a no-op). - let local = init_repo(); - seed_commit(local.path(), "x", "x\n", "x"); - push_branches( - Some(local.path()), - "origin", - &[], - false, - &NotesLease::RemoteAbsent, - ) - .unwrap(); - } - - #[test] - fn push_branches_lands_create_entries_against_a_bare_remote() { - // End-to-end: a Create entry pushes a new branch to the - // remote. `pull_head_sha == None` means the lease arg - // says "ref must not exist", so the push succeeds when - // the remote ref is absent. - let bare = init_bare_repo(); - let local = init_repo(); - let sha = seed_commit(local.path(), "x", "x\n", "x"); - run( - local.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - - let entries = vec![PushEntry { - commit_sha: sha.clone(), - dest_branch: "stack/feat/x".into(), - pull_head_sha: None, - }]; - push_branches( - Some(local.path()), - "origin", - &entries, - false, - &NotesLease::RemoteAbsent, - ) - .unwrap(); - - // Remote now has the branch pointed at the same SHA. - let remote_sha = - run_git_capture(Some(bare.path()), &["rev-parse", "refs/heads/stack/feat/x"]).unwrap(); - assert_eq!(remote_sha, sha); - } - - #[test] - fn push_branches_update_entry_fails_when_remote_diverged_from_lease() { - // Update entry with a stale lease SHA → the - // `--force-with-lease` rejects the push. Locks in the - // safety invariant: a concurrent push to the same PR - // must not silently clobber. - let bare = init_bare_repo(); - - // Producer: seed remote so the branch exists. - let producer = init_repo(); - let producer_sha = seed_commit(producer.path(), "x", "x\n", "x"); - run( - producer.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - run( - producer.path(), - &[ - "push", - "origin", - &format!("{producer_sha}:refs/heads/stack/feat/x"), - ], - ); - - // Local: pretend a different SHA used to be there. - let local = init_repo(); - let local_sha = seed_commit(local.path(), "y", "y\n", "y"); - run( - local.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - - let entries = vec![PushEntry { - commit_sha: local_sha, - dest_branch: "stack/feat/x".into(), - // Stale: not equal to producer_sha on the remote. - pull_head_sha: Some("0000000000000000000000000000000000000000".into()), - }]; - let err = push_branches( - Some(local.path()), - "origin", - &entries, - false, - &NotesLease::RemoteAbsent, - ) - .expect_err("stale lease must reject"); - let CliError::Generic(msg) = err else { - panic!("expected Generic CliError"); - }; - assert!( - msg.contains("exited"), - "error must surface a non-zero git push exit ({msg})", - ); - - // Remote ref unchanged → safety invariant holds. - let remote_sha = - run_git_capture(Some(bare.path()), &["rev-parse", "refs/heads/stack/feat/x"]).unwrap(); - assert_eq!(remote_sha, producer_sha); - } - - #[test] - fn push_branches_notes_lease_rejects_when_remote_moved() { - // The core safety invariant this fix exists for: a lease - // captured at fetch time (`RemoteAt(v1)`) must reject the - // push once a concurrent `stack push` has moved the remote - // notes ref to `v2` — and the remote must be left exactly - // as producer B left it (no silent clobber, no partial - // atomic landing of the branch either). - let bare = init_bare_repo(); - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - - // Producer A seeds the remote with a commit + a note. - let producer_a = init_repo(); - let sha_a = seed_commit(producer_a.path(), "a", "a\n", "a"); - run( - producer_a.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - run( - producer_a.path(), - &["notes", ¬es_ref_arg, "add", "-m", "a", &sha_a], - ); - run(producer_a.path(), &["push", "origin", "main"]); - let force_notes_refspec = format!("+{STACK_NOTES_REF}:{STACK_NOTES_REF}"); - run(producer_a.path(), &["push", "origin", &force_notes_refspec]); - - // Consumer fetches: local ref is missing, so `fetch_notes_ref` - // takes the fetch-and-capture path, lands the notes ref - // locally at v1, and returns `RemoteAt(v1)`. - let consumer = init_repo(); - let consumer_sha = seed_commit(consumer.path(), "y", "y\n", "y"); - run( - consumer.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - let lease = fetch_notes_ref(Some(consumer.path()), "origin").unwrap(); - let NotesLease::RemoteAt(v1) = lease.clone() else { - panic!("expected RemoteAt, got {lease:?}"); - }; - assert_eq!( - rev_parse(consumer.path(), STACK_NOTES_REF), - Some(v1.clone()) - ); - - // Producer B force-pushes a divergent notes state to the - // remote after the consumer's fetch — the remote moves to v2. - let producer_b = init_repo(); - let sha_b = seed_commit(producer_b.path(), "b", "b\n", "b"); - run( - producer_b.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - run( - producer_b.path(), - &["notes", ¬es_ref_arg, "add", "-m", "b", &sha_b], - ); - run(producer_b.path(), &["push", "origin", &force_notes_refspec]); - let v2 = rev_parse(bare.path(), STACK_NOTES_REF).unwrap(); - assert_ne!(v1, v2, "producer B must have moved the remote notes ref"); - - // Consumer adds a local note (still built on the stale v1) - // and pushes with the now-stale `RemoteAt(v1)` lease. - run( - consumer.path(), - &["notes", ¬es_ref_arg, "add", "-m", "y", &consumer_sha], - ); - let entries = vec![PushEntry { - commit_sha: consumer_sha, - dest_branch: "stack/feat/y".into(), - pull_head_sha: None, - }]; - push_branches(Some(consumer.path()), "origin", &entries, false, &lease) - .expect_err("stale notes lease must reject the whole atomic push"); - - // Remote notes ref unchanged → no silent clobber. The - // branch must not have landed either (atomic push). - assert_eq!(rev_parse(bare.path(), STACK_NOTES_REF).unwrap(), v2); - assert!( - rev_parse(bare.path(), "refs/heads/stack/feat/y").is_none(), - "atomic push must not land the branch when the notes lease fails" - ); - } - - #[test] - fn push_branches_notes_lease_succeeds_when_lease_current() { - // The positive case: when nothing raced, the lease is - // still current at push time and the push (branch + notes) - // lands normally, moving the remote notes ref to the - // consumer's local SHA. - let bare = init_bare_repo(); - let notes_ref_arg = format!("--ref={STACK_NOTES_REF}"); - - // Producer seeds the remote with a commit + a note. - let producer = init_repo(); - let sha_a = seed_commit(producer.path(), "a", "a\n", "a"); - run( - producer.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - run( - producer.path(), - &["notes", ¬es_ref_arg, "add", "-m", "a", &sha_a], - ); - run(producer.path(), &["push", "origin", "main"]); - let force_notes_refspec = format!("+{STACK_NOTES_REF}:{STACK_NOTES_REF}"); - run(producer.path(), &["push", "origin", &force_notes_refspec]); - - // Consumer fetches, getting `RemoteAt(v1)` and landing the - // notes ref locally at v1. - let consumer = init_repo(); - let consumer_sha = seed_commit(consumer.path(), "y", "y\n", "y"); - run( - consumer.path(), - &["remote", "add", "origin", bare.path().to_str().unwrap()], - ); - let lease = fetch_notes_ref(Some(consumer.path()), "origin").unwrap(); - assert!(matches!(lease, NotesLease::RemoteAt(_))); - - // Consumer adds a local note, extending the notes ref past - // v1, then pushes with the still-current lease. - run( - consumer.path(), - &["notes", ¬es_ref_arg, "add", "-m", "y", &consumer_sha], - ); - let local_notes_sha = rev_parse(consumer.path(), STACK_NOTES_REF).unwrap(); - let entries = vec![PushEntry { - commit_sha: consumer_sha, - dest_branch: "stack/feat/y".into(), - pull_head_sha: None, - }]; - push_branches(Some(consumer.path()), "origin", &entries, false, &lease).unwrap(); - - // Remote notes ref moved to the consumer's local SHA. - assert_eq!( - rev_parse(bare.path(), STACK_NOTES_REF).unwrap(), - local_notes_sha - ); - } -} diff --git a/crates/mergify-stack/src/plan.rs b/crates/mergify-stack/src/plan.rs deleted file mode 100644 index d286e808..00000000 --- a/crates/mergify-stack/src/plan.rs +++ /dev/null @@ -1,353 +0,0 @@ -//! Full `stack push` planner — the layer above [`crate::changes::classify`] -//! that applies the orchestrator's CLI flags (`--next-only`, -//! `--only-update-existing-pulls`) and decorates each change with -//! the planning-time `dest_branch` / `base_branch` the upserter -//! needs. -//! -//! Ported from -//! `mergify_cli/stack/changes.py::get_changes`. The shape: -//! -//! 1. [`classify`] gives the per-commit base action -//! (`Create`/`Update`/`SkipMerged`/`SkipUpToDate`). -//! 2. Apply [`PlannerOpts`] overrides: -//! - `next_only && idx > 0` → action becomes [`SkipNextOnly`] -//! so only the bottom of the stack actually pushes. -//! - `only_update_existing_pulls && Create` → [`SkipCreate`] -//! so the planner can surface "would-be-created" without -//! opening a PR. -//! 3. Resolve `dest_branch`: -//! - Existing pull → its `head.ref` (so the PR's branch -//! survives renames). -//! - No pull → `{stack_prefix}/{slug}` where `slug` comes -//! from the Rust walker (Change-Id-stable across rewrites). -//! 4. Resolve `base_branch`: previous live change's `dest_branch` -//! or `opts.base_branch` for the bottom of the stack. -//! -//! [`classify`]: crate::changes::classify -//! [`SkipNextOnly`]: crate::changes::Action::SkipNextOnly -//! [`SkipCreate`]: crate::changes::Action::SkipCreate - -use mergify_core::CliError; -use serde::Serialize; -use serde_json::Value; - -use crate::changes::{Action, LocalChange, classify}; -use crate::local_commits::LocalCommit; -use crate::remote_changes::RemoteChange; - -/// Flags from the `stack push` CLI that change which commits the -/// planner pushes. -#[derive(Debug, Clone, Copy)] -pub struct PlannerOpts<'a> { - /// Branch prefix for synthesised PR branch names. Matches - /// Python's `stack_prefix` arg — `stack/<user>` by default. - pub stack_prefix: &'a str, - /// Trunk branch the bottom of the stack targets. Matches - /// Python's `base_branch` arg — typically `main`. - pub base_branch: &'a str, - /// `--only-update-existing-pulls`: a `Create` action gets - /// overridden to `SkipCreate` so the run surfaces the - /// would-be-created PR without actually opening one. - pub only_update_existing_pulls: bool, - /// `--next-only`: every change after the first gets - /// `SkipNextOnly` so only the bottom of the stack lands. - pub next_only: bool, -} - -/// One planned change: the base [`LocalChange`] plus the -/// dest/base branches the upserter needs. -#[derive(Debug, Clone, Serialize)] -pub struct PlannedChange { - #[serde(flatten)] - pub change: LocalChange, - /// Remote branch name the PR's head ref points at. - pub dest_branch: String, - /// Branch this PR targets (previous change's `dest_branch` - /// for non-bottom rows; `opts.base_branch` for the bottom). - pub base_branch: String, -} - -/// Planner output: per-commit `PlannedChange`s plus the orphan -/// PR payloads (open PRs whose Change-Id is no longer in the -/// local stack). -#[derive(Debug, Clone, Serialize)] -pub struct PlannedChanges { - pub locals: Vec<PlannedChange>, - pub orphans: Vec<Value>, -} - -/// Plan a `stack push` run. -/// -/// Wraps [`classify`] with the orchestrator's overrides and the -/// dest/base branch resolution. Returns the same orphan list -/// [`classify`] does — pass-through, no override applies. -pub fn plan( - local_commits: &[LocalCommit], - remote_changes: Vec<RemoteChange>, - opts: PlannerOpts<'_>, -) -> Result<PlannedChanges, CliError> { - let classified = classify(local_commits, remote_changes)?; - - // Lookup commit_sha → slug so we can rebuild dest_branch - // when a pull doesn't exist. The classifier dropped the - // slug; pull it back from the walker output. - let slug_by_sha: std::collections::HashMap<&str, &str> = local_commits - .iter() - .map(|c| (c.commit_sha.as_str(), c.slug.as_str())) - .collect(); - - let mut locals: Vec<PlannedChange> = Vec::with_capacity(classified.locals.len()); - - for (idx, mut change) in classified.locals.into_iter().enumerate() { - // CLI flag overrides. Order matters: --next-only first - // (it short-circuits everything past the bottom of the - // stack) so it doesn't fight with --only-update-existing-pulls. - if opts.next_only && idx > 0 { - change.action = Action::SkipNextOnly; - } else if opts.only_update_existing_pulls && matches!(change.action, Action::Create) { - change.action = Action::SkipCreate; - } - - // dest_branch: existing pull's head.ref wins (so a - // renamed PR branch survives), else synthesise from - // the slug. Python matches. - let dest_branch = change - .pull - .as_ref() - .and_then(|p| p.pointer("/head/ref")) - .and_then(Value::as_str) - .map_or_else( - || { - let slug = slug_by_sha - .get(change.commit_sha.as_str()) - .copied() - .unwrap_or(""); - format!("{}/{}", opts.stack_prefix, slug) - }, - str::to_owned, - ); - - // base_branch: previous PlannedChange's dest_branch, or - // the trunk for the bottom of the stack. Crucially the - // *previous PlannedChange* — not the previous LocalChange - // — so a SkipNextOnly chain still anchors on the - // bottom's dest_branch. - let base_branch = locals - .last() - .map_or_else(|| opts.base_branch.to_string(), |p| p.dest_branch.clone()); - - locals.push(PlannedChange { - change, - dest_branch, - base_branch, - }); - } - - Ok(PlannedChanges { - locals, - orphans: classified.orphans, - }) -} - -/// Convenience: rewrite every `SkipUpToDate` action to `Update` -/// after a rebase has been decided. Used by `stack push` when -/// the orchestrator chose to rebase but the planner had already -/// classified some commits as up-to-date — those PRs will get -/// new SHAs from the rebase, so they need to be updated too. -/// -/// Mirrors the Python `planned_changes.replace_local_action( -/// old="skip-up-to-date", new="update")` post-decision tweak. -pub fn promote_skip_up_to_date_to_update(planned: &mut PlannedChanges) { - for entry in &mut planned.locals { - if matches!(entry.change.action, Action::SkipUpToDate) { - entry.change.action = Action::Update; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn local(sha: &str, change_id: &str, title: &str) -> LocalCommit { - LocalCommit { - commit_sha: sha.to_string(), - title: title.to_string(), - message: String::new(), - change_id: change_id.to_string(), - slug: format!("{title}--00000000"), - note: String::new(), - } - } - - fn pull_open(change_id: &str, head_sha: &str, head_ref: &str, number: u64) -> RemoteChange { - RemoteChange { - change_id: change_id.to_string(), - pull: json!({ - "number": number, - "state": "open", - "merged_at": null, - "head": {"sha": head_sha, "ref": head_ref}, - }), - } - } - - fn opts<'a>(stack_prefix: &'a str, base_branch: &'a str) -> PlannerOpts<'a> { - PlannerOpts { - stack_prefix, - base_branch, - only_update_existing_pulls: false, - next_only: false, - } - } - - #[test] - fn dest_branch_synthesised_from_slug_when_no_pull() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let planned = plan( - &[local("abc", cid, "feat-a")], - vec![], - opts("stack/jd", "main"), - ) - .unwrap(); - assert_eq!(planned.locals[0].dest_branch, "stack/jd/feat-a--00000000"); - assert_eq!(planned.locals[0].base_branch, "main"); - } - - #[test] - fn dest_branch_uses_existing_pull_head_ref_when_present() { - // A PR rename (e.g. user reverted a renamed dest_branch on - // GitHub) must survive — the planner uses the live PR's - // head.ref, not the locally-derived slug. - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let pull = pull_open(cid, "abc", "stack/jd/custom-name", 1); - let planned = plan( - &[local("abc", cid, "feat-a")], - vec![pull], - opts("stack/jd", "main"), - ) - .unwrap(); - assert_eq!(planned.locals[0].dest_branch, "stack/jd/custom-name"); - } - - #[test] - fn base_branch_chains_to_previous_dest_branch() { - // Stack of two creates: first base = trunk, second base = - // first's dest_branch. That's how PR-base chaining - // produces the "stacked PR" review UX on GitHub. - let cid1 = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let cid2 = "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"; - let planned = plan( - &[local("abc", cid1, "feat-a"), local("def", cid2, "feat-b")], - vec![], - opts("stack/jd", "main"), - ) - .unwrap(); - assert_eq!(planned.locals[0].base_branch, "main"); - assert_eq!(planned.locals[1].base_branch, planned.locals[0].dest_branch); - } - - #[test] - fn next_only_skips_every_change_past_the_bottom() { - // bottom Create stays Create; everything above becomes - // SkipNextOnly regardless of what classify would have - // emitted (Update / SkipUpToDate / etc). - let cid1 = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let cid2 = "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"; - let cid3 = "Icccccccccccccccccccccccccccccccccccccc03"; - let planned = plan( - &[ - local("abc", cid1, "feat-a"), - local("def", cid2, "feat-b"), - local("ghi", cid3, "feat-c"), - ], - vec![pull_open(cid2, "def", "stack/jd/b", 2)], - PlannerOpts { - next_only: true, - ..opts("stack/jd", "main") - }, - ) - .unwrap(); - assert_eq!(planned.locals[0].change.action, Action::Create); - assert_eq!(planned.locals[1].change.action, Action::SkipNextOnly); - assert_eq!(planned.locals[2].change.action, Action::SkipNextOnly); - } - - #[test] - fn only_update_existing_pulls_turns_create_into_skip_create() { - // `Create` → `SkipCreate` so the dry-run path can surface - // "would-be-created" without opening a PR. Updates and - // skips pass through unchanged. - let cid1 = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let cid2 = "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"; - let planned = plan( - &[local("abc", cid1, "feat-a"), local("def", cid2, "feat-b")], - vec![pull_open(cid2, "xxx", "stack/jd/b", 2)], - PlannerOpts { - only_update_existing_pulls: true, - ..opts("stack/jd", "main") - }, - ) - .unwrap(); - assert_eq!(planned.locals[0].change.action, Action::SkipCreate); - assert_eq!(planned.locals[1].change.action, Action::Update); - } - - #[test] - fn next_only_wins_over_only_update_existing_pulls_above_the_bottom() { - // Both flags set, idx > 0 → `SkipNextOnly` wins over - // `SkipCreate`. Order in the planner matters: a - // regression would have made every `Create` above the - // bottom show up as `SkipCreate` instead of - // `SkipNextOnly`, which the dry-run surfaces differently. - // (At idx == 0 only_update_existing_pulls applies and the - // bottom becomes `SkipCreate` — that's Python's behaviour - // too.) - let cid1 = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let cid2 = "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02"; - let planned = plan( - &[local("abc", cid1, "feat-a"), local("def", cid2, "feat-b")], - vec![], - PlannerOpts { - next_only: true, - only_update_existing_pulls: true, - ..opts("stack/jd", "main") - }, - ) - .unwrap(); - assert_eq!(planned.locals[0].change.action, Action::SkipCreate); - assert_eq!(planned.locals[1].change.action, Action::SkipNextOnly); - } - - #[test] - fn promote_rewrites_skip_up_to_date_to_update() { - // Post-rebase-decision tweak: a `SkipUpToDate` PR's SHA - // will change after the rebase, so the orchestrator - // promotes it to `Update` to force the push. - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let mut planned = plan( - &[local("abc", cid, "feat-a")], - vec![pull_open(cid, "abc", "stack/jd/feat-a", 1)], - opts("stack/jd", "main"), - ) - .unwrap(); - assert_eq!(planned.locals[0].change.action, Action::SkipUpToDate); - promote_skip_up_to_date_to_update(&mut planned); - assert_eq!(planned.locals[0].change.action, Action::Update); - } - - #[test] - fn orphans_pass_through_from_classify() { - // The planner doesn't override orphan behaviour — the - // CLI's orphan-handling logic stays in the orchestrator. - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let planned = plan( - &[], - vec![pull_open(cid, "abc", "stack/jd/feat-x", 7)], - opts("stack/jd", "main"), - ) - .unwrap(); - assert_eq!(planned.orphans.len(), 1); - } -} diff --git a/crates/mergify-stack/src/plan_display.rs b/crates/mergify-stack/src/plan_display.rs deleted file mode 100644 index 60ba83af..00000000 --- a/crates/mergify-stack/src/plan_display.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! Shared renderer for the full-stack action-plan previews printed -//! by `drop`/`squash`/`fixup`/`reword`/`reorder`/`move`. -//! -//! Port of `mergify_cli/stack/reorder.py::{display_plan, -//! display_action_plan}`. Every plan lists the ENTIRE stack in -//! `base..HEAD` order, numbered 1..N, each row showing the 12-char -//! SHA, the subject, the 12-char Change-Id (when present), and an -//! optional ` [<action>]` tag. The render is plain text — no color, -//! no Rich markup — matching the CLI's deliberate plain output. - -/// One row of a plan preview: a single stack commit plus the -/// action (if any) that this command applies to it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PlanRow { - /// Full 40-hex commit SHA. Truncated to 12 chars when rendered. - pub sha: String, - /// Commit subject line. - pub subject: String, - /// The commit's `Change-Id:` trailer value, or empty string - /// when absent. Rendered as ` (<change_id[:12]>)` when non-empty. - pub change_id: String, - /// The action tag for this row (e.g. `drop`, `fixup`, `reword`, - /// `amend`), or `None` for untouched rows / tag-less plans. - pub action: Option<String>, -} - -/// Render a plan preview: the `title` line followed by one -/// formatted line per row, mirroring Python's -/// `display_plan` / `display_action_plan`. -/// -/// Each row is ` {idx}. {sha[:12]} {subject}{cid}{tag}` where -/// `idx` is 1-based, `cid` is ` ({change_id[:12]})` when the -/// Change-Id is non-empty, and `tag` is ` [{action}]` when an -/// action is set. -#[must_use] -pub fn render_plan(title: &str, rows: &[PlanRow]) -> Vec<String> { - let mut out = Vec::with_capacity(rows.len() + 1); - out.push(title.to_string()); - for (idx, row) in rows.iter().enumerate() { - let sha = truncate(&row.sha, 12); - let cid = if row.change_id.is_empty() { - String::new() - } else { - format!(" ({})", truncate(&row.change_id, 12)) - }; - let tag = match &row.action { - Some(action) => format!(" [{action}]"), - None => String::new(), - }; - out.push(format!( - " {n}. {sha} {subject}{cid}{tag}", - n = idx + 1, - subject = row.subject, - )); - } - out -} - -fn truncate(s: &str, max: usize) -> &str { - &s[..s.len().min(max)] -} - -#[cfg(test)] -mod tests { - use super::*; - - fn row(sha: &str, subject: &str, change_id: &str, action: Option<&str>) -> PlanRow { - PlanRow { - sha: sha.to_string(), - subject: subject.to_string(), - change_id: change_id.to_string(), - action: action.map(str::to_string), - } - } - - #[test] - fn title_only_when_no_rows() { - assert_eq!(render_plan("Drop plan:", &[]), vec!["Drop plan:"]); - } - - #[test] - fn cid_shown_when_present() { - let rows = [row( - "abc123def4567890", - "Add the thing", - "I0123456789abcdef", - None, - )]; - assert_eq!( - render_plan("Move plan:", &rows), - vec![ - "Move plan:".to_string(), - " 1. abc123def456 Add the thing (I0123456789a)".to_string(), - ], - ); - } - - #[test] - fn cid_omitted_when_absent() { - let rows = [row("abc123def4567890", "No change id", "", None)]; - assert_eq!( - render_plan("Move plan:", &rows), - vec![ - "Move plan:".to_string(), - " 1. abc123def456 No change id".to_string(), - ], - ); - } - - #[test] - fn action_tag_appended_when_present() { - let rows = [row( - "abc123def4567890", - "Drop me", - "I0123456789abcdef", - Some("drop"), - )]; - assert_eq!( - render_plan("Drop plan:", &rows), - vec![ - "Drop plan:".to_string(), - " 1. abc123def456 Drop me (I0123456789a) [drop]".to_string(), - ], - ); - } - - #[test] - fn action_tag_without_cid() { - let rows = [row("abc123def4567890", "Drop me", "", Some("drop"))]; - assert_eq!( - render_plan("Drop plan:", &rows), - vec![ - "Drop plan:".to_string(), - " 1. abc123def456 Drop me [drop]".to_string(), - ], - ); - } - - #[test] - fn sha_and_change_id_truncated_to_twelve() { - // Full 40-hex SHA and full I+40-hex Change-Id both truncate - // to exactly 12 chars. - let rows = [row( - "0123456789abcdef0123456789abcdef01234567", - "Long ids", - "I0123456789abcdef0123456789abcdef01234567", - Some("fixup"), - )]; - assert_eq!( - render_plan("Fixup plan:", &rows), - vec![ - "Fixup plan:".to_string(), - " 1. 0123456789ab Long ids (I0123456789a) [fixup]".to_string(), - ], - ); - } - - #[test] - fn short_sha_and_cid_are_left_intact() { - // Inputs shorter than 12 chars must not panic and pass - // through unchanged. - let rows = [row("abc", "Short", "I12", None)]; - assert_eq!( - render_plan("Reorder plan:", &rows), - vec![ - "Reorder plan:".to_string(), - " 1. abc Short (I12)".to_string(), - ], - ); - } - - #[test] - fn multi_row_numbering_is_one_based_and_sequential() { - let rows = [ - row("aaaa111111111111", "First", "Iaaaa11112222", Some("fixup")), - row("bbbb222222222222", "Second", "", None), - row("cccc333333333333", "Third", "Icccc33334444", None), - ]; - assert_eq!( - render_plan("Squash plan:", &rows), - vec![ - "Squash plan:".to_string(), - " 1. aaaa11111111 First (Iaaaa1111222) [fixup]".to_string(), - " 2. bbbb22222222 Second".to_string(), - " 3. cccc33333333 Third (Icccc3333444)".to_string(), - ], - ); - } -} diff --git a/crates/mergify-stack/src/pr_upsert.rs b/crates/mergify-stack/src/pr_upsert.rs deleted file mode 100644 index c0c3e493..00000000 --- a/crates/mergify-stack/src/pr_upsert.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Per-PR upsert + orphan-branch teardown for `stack push`. -//! -//! - [`create_or_update_pr`] — the `Create` action `POST`s a -//! fresh PR; `Update` `PATCH`es the existing one with refreshed -//! `head`/`title`/`body`, plus `base` only when the PR is really -//! being retargeted (see [`base_if_changed`]). Body always goes through -//! [`crate::push_helpers::format_pull_description`] so the -//! `Change-Id:` trailer is stripped and the rendered -//! `Depends-On:` header points at the current predecessor PR — -//! unless the orchestrator suppressed it because GitHub is taking -//! over the ordering, in which case [`restore_depends_on`] writes it -//! back if that hand-over did not happen. -//! - [`delete_orphan_branch`] — `DELETE -//! /repos/<u>/<r>/git/refs/heads/<branch>` for orphan PRs the -//! classifier flagged (open PR whose Change-Id is no longer in -//! the local stack). Uses `delete_if_exists` so a concurrent -//! teardown won't surface a 404 to the caller. -//! -//! Ported from -//! `mergify_cli/stack/push.py::{create_or_update_pr, delete_stack}`. - -use mergify_core::{CliError, DeleteOutcome, HttpClient}; -use serde::Serialize; -use serde_json::Value; - -use crate::changes::Action; -use crate::push_helpers::format_pull_description; - -/// Inputs to [`create_or_update_pr`]. Decoupled from -/// [`crate::changes::LocalChange`] because the orchestrator -/// computes `dest_branch` / `base_branch` separately during -/// planning and the upserter shouldn't depend on the full -/// classifier output. -#[derive(Debug, Clone)] -pub struct PrUpsertInput<'a> { - pub action: Action, - /// Commit title — used as the PR title when - /// `keep_pull_request_title_and_body` is `false`. - pub title: &'a str, - /// Commit message body. Passed through - /// `format_pull_description` to produce the PR body when - /// `keep_pull_request_title_and_body` is `false`. - pub message: &'a str, - /// Remote branch name `mergify stack` pushed the commit to - /// — becomes the PR `head`. - pub dest_branch: &'a str, - /// Branch the PR targets (the predecessor PR's `dest_branch` - /// for non-bottom rows, the trunk branch for the bottom). - pub base_branch: &'a str, - /// Existing PR payload for `Update`; ignored for `Create`. - /// `None` on `Update` is a programmer error and surfaces as - /// `CliError::Generic` (matches Python's `RuntimeError`). - pub pull: Option<&'a Value>, - /// PR number of the predecessor PR — produces the trailing - /// `Depends-On: #<n>` in the body. `None` for the bottom of - /// the stack. - pub depends_on_number: Option<u64>, - pub create_as_draft: bool, - /// When `true`, `Update` keeps the existing PR title and - /// rewrites only the body (still re-running - /// `format_pull_description` on the existing body so a - /// stale `Depends-On:` gets re-chained). Mirrors the - /// `--keep-pull-request-title-and-body` flag. - pub keep_pull_request_title_and_body: bool, -} - -#[derive(Serialize)] -struct UpdateBodyBoth<'a> { - head: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - base: Option<&'a str>, - title: &'a str, - body: String, -} - -#[derive(Serialize)] -struct UpdateBodyKeepTitle<'a> { - head: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - base: Option<&'a str>, - body: String, -} - -#[derive(Serialize)] -struct CreateBody<'a> { - title: &'a str, - body: String, - draft: bool, - head: &'a str, - base: &'a str, -} - -/// The `base` value to PATCH, or `None` when the PR already targets -/// `planned` and the key must be left out of the body entirely. -/// -/// Omitting it is not just economy. GitHub treats the *presence* of -/// `base` as a retarget attempt: while a PR belongs to a native stack, -/// `PATCH /repos/{owner}/{repo}/pulls/{n}` carrying `base` fails with -/// 422 *"Cannot change the base branch because the pull request is -/// part of a stack"* even when the value is the one the PR already has -/// (measured against the live API, 2026-08-05). Sending it only when -/// it actually moves is what lets a routine push run over a registered -/// stack untouched — see [`crate::native_stack`]. -/// -/// A payload without `base.ref` (a mock, a proxy, a future API -/// version) falls back to sending the key, which is what this code did -/// unconditionally before. -/// -/// `head` needs no such care: it is not an updatable field, and GitHub -/// ignores it silently — a PATCH carrying a `head` that does not even -/// exist is a 200 that changes nothing, stacked or not (measured the -/// same day). We keep sending it for parity with the Python -/// implementation. -fn base_if_changed<'a>(pull: &Value, planned: &'a str) -> Option<&'a str> { - match pull.pointer("/base/ref").and_then(Value::as_str) { - Some(current) if current == planned => None, - _ => Some(planned), - } -} - -/// The text [`create_or_update_pr`] renders the PR description from: -/// the commit message, or the pull request's own current body when -/// `--keep-pull-request-title-and-body` is in force on an `Update`. -/// -/// Exposed so the orchestrator can re-render the *same* description -/// with a different `Depends-On:` target without duplicating that -/// choice — see [`restore_depends_on`]. The two must agree: rendering -/// the message where the upsert rendered the existing body would -/// overwrite a PR description the user asked to keep. -#[must_use] -pub(crate) fn description_source<'a>(input: &PrUpsertInput<'a>) -> &'a str { - if matches!(input.action, Action::Update) && input.keep_pull_request_title_and_body { - input - .pull - .and_then(|pull| pull.get("body")) - .and_then(Value::as_str) - .unwrap_or("") - } else { - input.message - } -} - -/// Upsert the PR for `input.action` and return the PR payload. -/// -/// `Update` returns the existing pull verbatim (Python does the -/// same; the PATCH response is ignored). `Create` returns the -/// freshly-created PR payload from the POST response so the -/// orchestrator can stash the number for downstream `Depends-On:` -/// links. -/// -/// `SkipMerged` / `SkipUpToDate` are *not* valid inputs — the -/// orchestrator filters them out before calling. Passing one -/// surfaces as `CliError::Generic` (matches Python's -/// `RuntimeError("Unhandled action: ...")`). -pub async fn create_or_update_pr( - client: &HttpClient, - user: &str, - repo: &str, - input: PrUpsertInput<'_>, -) -> Result<Value, CliError> { - match input.action { - Action::Update => { - let pull = input.pull.ok_or_else(|| { - CliError::Generic("Can't update pull with change.pull unset".to_string()) - })?; - let number = pull - .get("number") - .and_then(Value::as_u64) - .ok_or_else(|| CliError::Generic("update pull payload missing `number`".into()))?; - let path = format!("/repos/{user}/{repo}/pulls/{number}"); - - let base = base_if_changed(pull, input.base_branch); - - // Two PATCH body shapes for the same endpoint: when - // `keep_pull_request_title_and_body` is true we want - // GitHub to leave `title` alone, so we just don't - // include the key. Sending `title: null` would - // actually try to clear it — different from "don't - // touch." - let description = - format_pull_description(description_source(&input), input.depends_on_number); - if input.keep_pull_request_title_and_body { - let body = UpdateBodyKeepTitle { - head: input.dest_branch, - base, - body: description, - }; - let _: Value = client.patch(&path, &body).await?; - } else { - let body = UpdateBodyBoth { - head: input.dest_branch, - base, - title: input.title, - body: description, - }; - let _: Value = client.patch(&path, &body).await?; - } - - Ok(pull.clone()) - } - Action::Create => { - let body = CreateBody { - title: input.title, - body: format_pull_description(description_source(&input), input.depends_on_number), - draft: input.create_as_draft, - head: input.dest_branch, - base: input.base_branch, - }; - let path = format!("/repos/{user}/{repo}/pulls"); - let pull: Value = client.post(&path, &body).await?; - Ok(pull) - } - Action::SkipMerged | Action::SkipUpToDate | Action::SkipCreate | Action::SkipNextOnly => { - Err(CliError::Generic(format!( - "Unhandled action: {:?}", - input.action, - ))) - } - } -} - -#[derive(Serialize)] -struct BodyPatch { - body: String, -} - -/// Write the `Depends-On: #<n>` marker back into a pull request body -/// that was rendered without one. -/// -/// The undo half of the github-native optimism: the upsert leaves -/// the marker out because GitHub is about to hold the order itself, -/// and this puts it back for the pushes where GitHub did not (see -/// [`crate::native_stack::register`], which degrades to `None` rather -/// than failing). `source` is the same string -/// [`description_source`] handed the upsert, so the body it writes -/// differs from the one already there by the marker and nothing else. -/// -/// `body` is the only key sent. A PATCH carrying `base` is what a -/// registered stack rejects (see [`base_if_changed`]), and this call -/// site has no business touching the base anyway — by the time it -/// runs, the push has finished retargeting. -pub(crate) async fn restore_depends_on( - client: &HttpClient, - user: &str, - repo: &str, - number: u64, - source: &str, - depends_on_number: u64, -) -> Result<(), CliError> { - let path = format!("/repos/{user}/{repo}/pulls/{number}"); - let body = BodyPatch { - body: format_pull_description(source, Some(depends_on_number)), - }; - let _: Value = client.patch(&path, &body).await?; - Ok(()) -} - -/// One PR whose base is about to move — input to -/// [`neutralize_stale_bases`]. The orchestrator builds this from -/// the planned-changes list before the force-push. -#[derive(Debug, Clone)] -pub struct StaleBase<'a> { - pub pull_number: u64, - /// PR's *current* `base.ref` on GitHub. - pub current_base_ref: &'a str, - /// `base_branch` the planner picked for this row. If equal - /// to `current_base_ref` the PR isn't moving and gets - /// skipped by [`neutralize_stale_bases`]. - pub new_base_ref: &'a str, -} - -#[derive(Serialize)] -struct BasePatch<'a> { - base: &'a str, -} - -/// Repoint at-risk PR bases onto the trunk before the force-push. -/// -/// When commits are reordered, a PR's branch can move *below* a -/// commit that used to sit beneath it. The atomic force-push -/// then rewrites every head branch at once, while the PRs on -/// GitHub still carry their pre-reorder bases. During that -/// window a PR's head branch can become an ancestor of its own -/// (stale) base branch — e.g. the new-bottom PR's head ends up -/// contained in the branch that now holds the commit stacked on -/// top of it. GitHub interprets "head is an ancestor of base" -/// as "merged" and irreversibly auto-closes the PR (and its -/// dependents). -/// -/// To avoid that, any PR whose base is moving is first -/// repointed to the trunk: a head branch is never an ancestor -/// of the trunk, so the upcoming force-push cannot trigger the -/// spurious merge. The real new base is set afterwards by -/// [`create_or_update_pr`] once every branch holds its final -/// content. -/// -/// PRs already on the trunk, or whose base hasn't changed, are -/// left alone — the common non-reorder push issues zero extra -/// API calls. -/// -/// Ported from -/// `mergify_cli/stack/push.py::neutralize_stale_bases` (#1559). -pub async fn neutralize_stale_bases( - client: &HttpClient, - user: &str, - repo: &str, - candidates: &[StaleBase<'_>], - trunk_base: &str, -) -> Result<(), CliError> { - // Sequential to stay under GitHub's secondary rate-limit — - // same trade-off `remote_changes` and `approvals` make. The - // common case (no reorder) iterates zero PRs anyway. - for c in candidates { - if c.current_base_ref == trunk_base || c.current_base_ref == c.new_base_ref { - continue; - } - let path = format!("/repos/{user}/{repo}/pulls/{}", c.pull_number); - let _: Value = client.patch(&path, &BasePatch { base: trunk_base }).await?; - } - Ok(()) -} - -/// Delete the remote branch for an orphan PR. -/// -/// Orphan PRs are open PRs whose `Change-Id` is no longer in the -/// local stack — typically because the user dropped a commit -/// locally without closing the PR. Tearing them down keeps the -/// stack consistent with the local series. 404s are swallowed -/// (matches `delete_if_exists` semantics) so a concurrent -/// teardown by another tool doesn't surface as an error. -pub async fn delete_orphan_branch( - client: &HttpClient, - user: &str, - repo: &str, - branch_ref: &str, -) -> Result<DeleteOutcome, CliError> { - let path = format!("/repos/{user}/{repo}/git/refs/heads/{branch_ref}"); - client.delete_if_exists(&path).await -} - -#[cfg(test)] -mod tests { - use super::*; - use mergify_core::{ApiFlavor, HttpClient}; - use serde_json::json; - use url::Url; - use wiremock::matchers::{method, path as wm_path}; - use wiremock::{Mock, MockServer, Request, ResponseTemplate}; - - fn client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "token", - ApiFlavor::GitHub, - ) - .unwrap() - } - - fn request_body(req: &Request) -> Value { - serde_json::from_slice(&req.body).expect("body is json") - } - - #[tokio::test] - async fn create_posts_pulls_endpoint_with_draft_and_full_body() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/pulls")) - .respond_with(ResponseTemplate::new(201).set_body_json(json!({"number": 42}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Create, - title: "feat: x", - message: "feat: x\n\nbody\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n", - dest_branch: "jd/feature/Iaaaaaa", - base_branch: "main", - pull: None, - depends_on_number: Some(7), - create_as_draft: true, - keep_pull_request_title_and_body: false, - }; - let pull = create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - assert_eq!(pull["number"], 42); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - assert_eq!(body["title"], "feat: x"); - assert_eq!(body["draft"], true); - assert_eq!(body["head"], "jd/feature/Iaaaaaa"); - assert_eq!(body["base"], "main"); - // Description has Change-Id stripped and predecessor PR - // appended as Depends-On. - let body_str = body["body"].as_str().unwrap(); - assert!(!body_str.contains("Change-Id")); - assert!(body_str.ends_with("\n\nDepends-On: #7")); - } - - #[tokio::test] - async fn update_patches_pulls_endpoint_and_returns_existing_pull() { - let server = MockServer::start().await; - let existing = json!({ - "number": 42, - "body": "old body\n\nDepends-On: #999", - // Base is moving (stack/x → main), so the key is sent. - "base": {"ref": "stack/x"}, - }); - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Update, - title: "feat: x (rewritten)", - message: "feat: x\n\nfresh body", - dest_branch: "jd/feature/Iaaaaaa", - base_branch: "main", - pull: Some(&existing), - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: false, - }; - let pull = create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - // Update returns the input pull verbatim — Python pins - // this so downstream code can use it as-is without - // re-reading the PATCH response. - assert_eq!(pull, existing); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - assert_eq!(body["title"], "feat: x (rewritten)"); - assert_eq!(body["head"], "jd/feature/Iaaaaaa"); - assert_eq!(body["base"], "main"); - // Body is the commit message, not the old PR body, when - // keep_pull_request_title_and_body is false. - assert_eq!(body["body"], "feat: x\n\nfresh body"); - } - - #[tokio::test] - async fn update_omits_base_when_the_pull_already_targets_it() { - // The routine push: nothing was reordered, so the PR's base is - // the one the planner picked. Sending it anyway is what makes - // GitHub 422 the PATCH while the PR is in a native stack, even - // though nothing is moving. - let server = MockServer::start().await; - let existing = json!({ - "number": 42, - "body": "old body", - "base": {"ref": "stack/tester/feature/a--Iaaaaaa"}, - }); - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Update, - title: "feat: x", - message: "feat: x", - dest_branch: "jd/feature/Ibbbbbb", - base_branch: "stack/tester/feature/a--Iaaaaaa", - pull: Some(&existing), - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: false, - }; - create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - assert!( - body.get("base").is_none(), - "an unchanged base must not be sent at all, got: {body}", - ); - // The rest of the update still happens. - assert_eq!(body["title"], "feat: x"); - assert_eq!(body["head"], "jd/feature/Ibbbbbb"); - } - - #[tokio::test] - async fn update_sends_base_when_the_pull_is_actually_retargeted() { - // The reorder/drop path: the planner moved this PR, so the key - // has to be there — and this is the case that legitimately - // needs the native stack dissolved first. - let server = MockServer::start().await; - let existing = json!({ - "number": 42, - "body": "old body", - "base": {"ref": "stack/tester/feature/a--Iaaaaaa"}, - }); - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Update, - title: "feat: x", - message: "feat: x", - dest_branch: "jd/feature/Ibbbbbb", - base_branch: "main", - pull: Some(&existing), - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: false, - }; - create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - assert_eq!(body["base"], "main"); - } - - #[tokio::test] - async fn update_sends_base_when_the_payload_does_not_say_what_it_is() { - // Unknown current base → behave as this code always did and - // send it. Better a redundant key than a PR left on a stale - // base because a payload was missing a field. - let server = MockServer::start().await; - let existing = json!({"number": 42, "body": "old body"}); - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Update, - title: "feat: x", - message: "feat: x", - dest_branch: "b", - base_branch: "main", - pull: Some(&existing), - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: true, - }; - create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - assert_eq!(body["base"], "main"); - } - - #[tokio::test] - async fn update_with_keep_title_omits_title_and_rewrites_body_from_existing() { - // The existing PR body's `Depends-On: #999` gets rewritten - // through `format_pull_description` so the stale predecessor - // doesn't survive. With `depends_on_number: None` the new - // body has no Depends-On at all. - let server = MockServer::start().await; - let existing = json!({ - "number": 42, - "body": "old body\n\nDepends-On: #999", - }); - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let input = PrUpsertInput { - action: Action::Update, - title: "ignored", - message: "ignored", - dest_branch: "jd/feature/Iaaaaaa", - base_branch: "main", - pull: Some(&existing), - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: true, - }; - create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap(); - - let body = request_body(&server.received_requests().await.unwrap()[0]); - // `title` key absent — GitHub interprets that as "don't - // touch", which is what `--keep-pull-request-title-and-body` - // means. - assert!(body.get("title").is_none(), "title must be absent"); - let new_body = body["body"].as_str().unwrap(); - assert!(!new_body.contains("Depends-On")); - assert!(new_body.starts_with("old body")); - } - - #[tokio::test] - async fn update_without_existing_pull_errors() { - let server = MockServer::start().await; - let input = PrUpsertInput { - action: Action::Update, - title: "x", - message: "x", - dest_branch: "b", - base_branch: "main", - pull: None, - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: false, - }; - let err = create_or_update_pr(&client(&server), "o", "r", input) - .await - .unwrap_err(); - let CliError::Generic(msg) = err else { - panic!("expected Generic"); - }; - assert!(msg.contains("change.pull unset")); - } - - #[tokio::test] - async fn skip_actions_are_rejected() { - // SkipMerged / SkipUpToDate are filtered by the - // orchestrator before the upserter runs; if one slips - // through, surface a clear error rather than silently - // doing nothing. - for action in [Action::SkipMerged, Action::SkipUpToDate] { - let server = MockServer::start().await; - let input = PrUpsertInput { - action, - title: "x", - message: "x", - dest_branch: "b", - base_branch: "main", - pull: None, - depends_on_number: None, - create_as_draft: false, - keep_pull_request_title_and_body: false, - }; - assert!( - create_or_update_pr(&client(&server), "o", "r", input) - .await - .is_err() - ); - } - } - - #[tokio::test] - async fn delete_orphan_branch_swallows_404() { - // Idempotent teardown: 404 → Ok(NotFound) so a concurrent - // tool that already deleted the branch doesn't crash - // our run. - let server = MockServer::start().await; - Mock::given(method("DELETE")) - .and(wm_path("/repos/o/r/git/refs/heads/orphan-branch")) - .respond_with(ResponseTemplate::new(404)) - .mount(&server) - .await; - let outcome = delete_orphan_branch(&client(&server), "o", "r", "orphan-branch") - .await - .unwrap(); - assert_eq!(outcome, DeleteOutcome::NotFound); - } - - #[tokio::test] - async fn delete_orphan_branch_returns_deleted_on_2xx() { - let server = MockServer::start().await; - Mock::given(method("DELETE")) - .and(wm_path("/repos/o/r/git/refs/heads/orphan-branch")) - .respond_with(ResponseTemplate::new(204)) - .mount(&server) - .await; - let outcome = delete_orphan_branch(&client(&server), "o", "r", "orphan-branch") - .await - .unwrap(); - assert_eq!(outcome, DeleteOutcome::Deleted); - } - - #[tokio::test] - async fn neutralize_repoints_only_prs_whose_base_is_moving() { - // Three candidates: one is already on the trunk (skip), - // one is moving (PATCH), one's base is unchanged (skip). - // The trip-wire is mounting PATCH mocks ONLY for the - // moving PR — wiremock would 404 any extra call, which - // would surface as Err from the client. - let server = MockServer::start().await; - Mock::given(method("PATCH")) - .and(wm_path("/repos/o/r/pulls/42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) - .mount(&server) - .await; - - let candidates = vec![ - // Already on trunk → skip. - StaleBase { - pull_number: 1, - current_base_ref: "main", - new_base_ref: "stack/jd/feat-a", - }, - // Moving (current != trunk and current != new) → PATCH. - StaleBase { - pull_number: 42, - current_base_ref: "stack/jd/feat-b", - new_base_ref: "stack/jd/feat-a", - }, - // Base unchanged → skip. - StaleBase { - pull_number: 99, - current_base_ref: "stack/jd/feat-c", - new_base_ref: "stack/jd/feat-c", - }, - ]; - neutralize_stale_bases(&client(&server), "o", "r", &candidates, "main") - .await - .unwrap(); - - // Exactly one PATCH, body repoints to the trunk. - let reqs = server.received_requests().await.unwrap(); - let patches: Vec<_> = reqs - .iter() - .filter(|r| r.method.as_str() == "PATCH") - .collect(); - assert_eq!(patches.len(), 1, "only the moving PR gets repointed"); - let body: Value = serde_json::from_slice(&patches[0].body).unwrap(); - assert_eq!(body, json!({"base": "main"})); - } - - #[tokio::test] - async fn neutralize_empty_candidates_is_noop() { - // The common non-reorder push: nothing to repoint, no - // API calls. Wiremock with no mocks → any HTTP call - // would 404 → assert success proves we issued zero. - let server = MockServer::start().await; - neutralize_stale_bases(&client(&server), "o", "r", &[], "main") - .await - .unwrap(); - } -} diff --git a/crates/mergify-stack/src/progress.rs b/crates/mergify-stack/src/progress.rs deleted file mode 100644 index b4811a8f..00000000 --- a/crates/mergify-stack/src/progress.rs +++ /dev/null @@ -1,945 +0,0 @@ -//! Live, in-place progress display for `stack push`. -//! -//! `stack push` runs a sequence of slow network steps — git push, -//! one PR upsert per stacked commit, stack comments, revision -//! history. Done silently it looks frozen, especially on a slow -//! link. [`Progress`] renders one row per step and rewrites those -//! rows in place (spinner → ✓) as each completes, so the terminal -//! always shows what is happening *now*. -//! -//! On anything that is not an interactive terminal (a pipe, CI, -//! a redirect) it degrades to plain streaming: each row is printed -//! once, when it resolves, with no cursor control or color. Tests -//! and scripts therefore see deterministic line-per-step output. -//! -//! The same plain-streaming fallback also kicks in mid-flight when -//! the live block would grow taller than the viewport (see -//! [`Progress::would_overflow`]): a block taller than the terminal -//! can't be redrawn with a single cursor-up without corrupting -//! scrollback, so we flush what's resolved as permanent lines and -//! stream the rest. -//! -//! The full rendered text is also collected into a transcript -//! (returned by [`Progress::finish`]) so callers can keep the -//! buffered-lines contract the dry-run path still uses. - -use std::fmt::Write as _; -use std::io::{IsTerminal, Write}; -use std::time::Duration; - -use mergify_tui::Theme; -use unicode_width::UnicodeWidthChar; - -/// Braille spinner frames; each is one display column wide. -const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -/// Glyph advance cadence — fast enough to read as smooth motion. -const SPIN_INTERVAL: Duration = Duration::from_millis(80); - -/// How a finished row reads — drives both its glyph and color. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Mark { - /// The step did real work (push/create/update): green `✓`. - Done, - /// The step was a no-op (skipped/up-to-date/deleted): dim `·`. - Noop, - /// The commit was already merged on the trunk: magenta `·`, - /// matching GitHub's merged badge. A distinct variant rather - /// than a string-compare on the status word so the producer's - /// `SkipMerged` semantics drive the color directly. - Merged, -} - -/// Whether a row is a bare status step or a per-PR row. Explicit so -/// a legitimately empty PR title can't be mistaken for a step row -/// (which would mis-indent it and drop it from column alignment). -#[derive(Clone, Copy, PartialEq, Eq)] -enum RowKind { - Step, - Pr, -} - -#[derive(Clone, Copy)] -enum State { - Pending, - Active, - Resolved(Mark), -} - -struct Row { - kind: RowKind, - /// Long, truncatable middle (PR rows only). Empty for step rows - /// whose text lives entirely in `word`. - title: String, - /// Trailing status word ("queued" / "updating" / "updated"). - word: String, - /// Optional dim detail between the status tag and the url — the - /// short SHA transition (`old7→new7` for an update, `new7` for a - /// create). `None` leaves the segment out entirely. - detail: Option<String>, - /// PR URL, once known (a freshly-created PR has none until its - /// POST returns). It already carries the PR number, so the row - /// doesn't repeat it. - url: Option<String>, - state: State, -} - -pub struct Progress { - interactive: bool, - theme: Theme, - width: usize, - /// Terminal height in rows. A live block taller than this can't - /// be redrawn with a single cursor-up, so we fall back to plain - /// streaming before it overflows. - height: usize, - frame: usize, - rows: Vec<Row>, - /// Physical lines written by the last redraw, so the next one - /// knows how far up to move the cursor. - drawn: usize, - /// Cached `(status_w, url_w, detail_w)` PR-column widths. - /// Recomputed only when a row is added or its word/url/detail - /// changes — not on every redraw frame. `detail_w` keeps the SHA - /// detail a padded column so titles line up even when some rows - /// carry a detail and others don't. - cols: Option<(usize, usize, usize)>, - transcript: Vec<String>, -} - -impl Progress { - /// Detect the output mode from stdout. Interactive (cursor - /// redraw + spinner) only on a real terminal whose size we can - /// read. - /// - /// Color follows that same interactivity, so it is narrower than - /// the recorded `--color` choice: a non-interactive `Progress` is - /// always plain, including under `--color always`. Only the - /// interactive branch consults [`Theme::detect`] (the recorded - /// choice, else the TTY; off outside the CLI entry point, so - /// tests are never colored). See the comment in the body. - #[must_use] - pub fn new() -> Self { - let stdout = std::io::stdout(); - let (interactive, width, height) = match terminal_size::terminal_size() { - Some((terminal_size::Width(w), terminal_size::Height(h))) if stdout.is_terminal() => { - (true, w as usize, h as usize) - } - _ => (false, 0, 0), - }; - // Color follows interactivity: only an interactive terminal - // (one whose size we could read) gets ANSI. A TTY whose size - // probe failed streams plain, so it must stay plain — don't - // let `Theme::detect` (which keys only on is_terminal) leak - // color into that fallback. - let theme = if interactive { - Theme::detect() - } else { - Theme::new(false) - }; - Self::with_mode(interactive, theme, width, height) - } - - fn with_mode(interactive: bool, theme: Theme, width: usize, height: usize) -> Self { - Self { - interactive, - theme, - width: width.max(20), - height: height.max(1), - frame: 0, - rows: Vec::new(), - drawn: 0, - cols: None, - transcript: Vec::new(), - } - } - - /// Print a standalone line above or below the live block (the - /// header, the rebase note, the final summary). Only safe - /// before the first row is added or after [`finish`]; never - /// interleave with a live block. - pub fn note(&mut self, line: impl Into<String>) { - let line = line.into(); - println!("{line}"); - self.transcript.push(line); - } - - /// Add a plain status step (text in `word`, no PR title). Starts - /// `Pending`; interactive mode draws it immediately so the user - /// sees the whole plan up front. - pub fn add(&mut self, word: impl Into<String>) -> usize { - self.push_row(RowKind::Step, String::new(), word.into(), None, None); - self.rows.len() - 1 - } - - /// Add a PR row (truncatable `title`, optional dim SHA `detail`). - /// Starts `Pending`. - pub fn add_pr( - &mut self, - title: impl Into<String>, - word: impl Into<String>, - detail: Option<String>, - url: Option<String>, - ) -> usize { - self.push_row(RowKind::Pr, title.into(), word.into(), detail, url); - self.rows.len() - 1 - } - - fn push_row( - &mut self, - kind: RowKind, - title: String, - word: String, - detail: Option<String>, - url: Option<String>, - ) { - self.rows.push(Row { - kind, - title, - word, - detail, - url, - state: State::Pending, - }); - self.recompute_cols(); - // Adding a row may make the live block taller than the - // viewport; degrade to plain streaming before it overflows. - if self.interactive && self.would_overflow() { - self.degrade_to_streaming(); - } - self.redraw(); - } - - /// Add an already-finished step row (a skip that needs no - /// network call). - pub fn add_resolved(&mut self, mark: Mark, word: impl Into<String>) { - let idx = self.add(word); - self.resolve(idx, mark, None); - } - - /// Add an already-finished PR row (a skip/up-to-date/merged - /// commit that needs no network call). - pub fn add_resolved_pr( - &mut self, - title: impl Into<String>, - mark: Mark, - word: impl Into<String>, - url: Option<String>, - ) { - let idx = self.add_pr(title, word, None, url); - self.resolve(idx, mark, None); - } - - /// Mark a row active (spinner glyph) and relabel it, without - /// awaiting. The frame is advanced only by the tick loop in - /// [`Progress::run`], so the glyph stays put here — use this for - /// a synchronous step that can't be polled. - pub fn activate(&mut self, idx: usize, word: impl Into<String>) { - self.rows[idx].word = word.into(); - self.rows[idx].state = State::Active; - self.recompute_cols(); - self.redraw(); - } - - /// Finalize the rows drawn so far as permanent scrollback and - /// start a fresh block on the next draw. Call before emitting - /// output the reporter doesn't control (a child process's - /// stdout) so the next redraw won't move the cursor back up over - /// it. Prints nothing — the resolved lines stay on screen, so - /// the spinner never blinks out to a blank gap. - pub fn seal(&mut self) { - self.rows.clear(); - self.cols = None; - self.drawn = 0; - } - - /// Mark a row active and spin it smoothly while `fut` runs, - /// advancing the glyph on a fixed cadence regardless of when the - /// work makes progress. In plain mode this just awaits `fut`. - pub async fn run<F: std::future::Future>( - &mut self, - idx: usize, - active_word: impl Into<String>, - fut: F, - ) -> F::Output { - self.run_reporting(idx, active_word, fut, || None).await - } - - /// Run `fut` under row `opt_idx` when `Some`, else just await it. - /// Collapses the repeated `if let Some(idx) = … { run } else { - /// await }` branch at the call sites that only conditionally - /// have a row to attach the work to. - pub async fn run_optional<F: std::future::Future>( - &mut self, - opt_idx: Option<usize>, - active_word: impl Into<String>, - fut: F, - ) -> F::Output { - if let Some(idx) = opt_idx { - self.run(idx, active_word, fut).await - } else { - fut.await - } - } - - /// Like [`Progress::run`], but re-reads `label` on every tick so - /// a step can relabel itself mid-flight (e.g. the pull number the - /// fetch loop is currently on) while the glyph keeps spinning - /// smoothly. `label` returns `None` to leave the label untouched. - pub async fn run_reporting<F, L>( - &mut self, - idx: usize, - active_word: impl Into<String>, - fut: F, - mut label: L, - ) -> F::Output - where - F: std::future::Future, - L: FnMut() -> Option<String>, - { - self.activate(idx, active_word); - - if !self.interactive { - return fut.await; - } - let mut fut = std::pin::pin!(fut); - loop { - match tokio::time::timeout(SPIN_INTERVAL, fut.as_mut()).await { - Ok(out) => return out, - Err(_elapsed) => { - if let Some(word) = label() { - self.rows[idx].word = word; - self.recompute_cols(); - } - self.frame = (self.frame + 1) % FRAMES.len(); - self.redraw(); - } - } - } - } - - /// Resolve a row to its terminal state. `word` replaces the - /// trailing word when given (e.g. "updating" → "updated"). - pub fn resolve(&mut self, idx: usize, mark: Mark, word: Option<&str>) { - let row = &mut self.rows[idx]; - if let Some(w) = word { - row.word = w.to_string(); - } - row.state = State::Resolved(mark); - self.recompute_cols(); - let line = render_row( - &self.rows[idx], - self.frame, - usize::MAX, - &Theme::new(false), - None, - ); - self.transcript.push(line.clone()); - if self.interactive { - self.redraw(); - } else { - println!("{line}"); - } - } - - /// Fill in the url of a row whose PR was just created. - pub fn set_url(&mut self, idx: usize, url: Option<String>) { - if url.is_some() { - self.rows[idx].url = url; - self.recompute_cols(); - } - } - - /// Print the closing line and hand back the full transcript. - #[must_use] - pub fn finish(mut self, line: impl Into<String>) -> Vec<String> { - self.note(line); - self.transcript - } - - /// True when the current row count would not fit in the - /// viewport, leaving a row for the cursor. A block this tall - /// can't be redrawn with a single cursor-up. - fn would_overflow(&self) -> bool { - self.rows.len() > self.height.saturating_sub(1) - } - - /// Permanently flush the live block and switch to plain - /// streaming. Clears the current in-place block, reprints the - /// already-resolved rows as permanent scrollback lines, and - /// drops out of interactive mode so future resolves stream one - /// plain line each (no cursor-up — corruption-proof). Pending / - /// active rows are dropped from the block; they print when they - /// later resolve. - fn degrade_to_streaming(&mut self) { - let mut buf = String::new(); - if self.drawn > 0 { - // Move to the top of the block and erase it, so the - // resolved rows reprint cleanly as permanent lines. - let _ = write!(buf, "\x1b[{}A\r\x1b[0J", self.drawn); - } - for row in &self.rows { - if matches!(row.state, State::Resolved(_)) { - buf.push_str(&render_row( - row, - self.frame, - self.width, - &self.theme, - self.cols, - )); - buf.push('\n'); - } - } - let mut out = std::io::stdout().lock(); - let _ = out.write_all(buf.as_bytes()); - let _ = out.flush(); - self.interactive = false; - self.drawn = 0; - } - - fn redraw(&mut self) { - if !self.interactive { - return; - } - if let Some((terminal_size::Width(w), terminal_size::Height(h))) = - terminal_size::terminal_size() - { - self.width = (w as usize).max(20); - self.height = (h as usize).max(1); - } - let cols = self.cols; - let mut buf = String::new(); - if self.drawn > 0 { - let _ = write!(buf, "\x1b[{}A", self.drawn); - } - for row in &self.rows { - buf.push_str("\r\x1b[2K"); - buf.push_str(&render_row(row, self.frame, self.width, &self.theme, cols)); - buf.push('\n'); - } - self.drawn = self.rows.len(); - let mut out = std::io::stdout().lock(); - let _ = out.write_all(buf.as_bytes()); - let _ = out.flush(); - } - - /// Recompute and cache the widest `[status]` and url across the - /// current PR rows, so the live redraw can pad them to aligned - /// columns without re-measuring every frame. `None` when there - /// are no PR rows (nothing to align). - fn recompute_cols(&mut self) { - let mut status_w = 0; - let mut url_w = 0; - let mut detail_w = 0; - let mut any = false; - for r in self.rows.iter().filter(|r| r.kind == RowKind::Pr) { - any = true; - status_w = status_w.max(r.word.chars().count() + 2); // + "[]" - url_w = url_w.max(r.url.as_deref().map_or(0, |u| u.chars().count())); - detail_w = detail_w.max(r.detail.as_deref().map_or(0, |d| d.chars().count())); - } - self.cols = any.then_some((status_w, url_w, detail_w)); - } -} - -impl Default for Progress { - fn default() -> Self { - Self::new() - } -} - -/// Render one row to a single line. -/// -/// A step row is just `glyph + label`. A PR row is `glyph [status] -/// detail url title`, where `detail` (the dim SHA transition) is a -/// reserved column — a row without one keeps a blank slot rather than -/// dropping the field, so the url and title hold a fixed position -/// instead of sliding left: a display column in the live view, a -/// stable `cut -f` field when piped (tabs can't align display columns -/// once details differ in width). With -/// `cols = Some((status_w, url_w, detail_w))` status, detail, and url -/// are space-padded to those widths so rows line up under each other -/// (the live view); the detail column collapses only when no row in -/// the block carries one. With `cols = None` the same four fields are -/// tab-separated instead — every PR row emits all four (empty -/// detail/url included) so the piped transcript stays `cut -f`-able, -/// where streaming can't align anyway. The whole -/// line is truncated (tab-aware, Unicode-width-aware) to `width` so -/// the cursor-up redraw never wraps; pass `usize::MAX` for the -/// un-truncated transcript. Only the glyph and the `[status]`/detail -/// segments are colored, so the width budget is plain text. -fn render_row( - row: &Row, - frame: usize, - width: usize, - theme: &Theme, - cols: Option<(usize, usize, usize)>, -) -> String { - let (glyph, glyph_style) = match row.state { - State::Pending => ("◦", theme.dim), - State::Active => (FRAMES[frame % FRAMES.len()], theme.cyan), - State::Resolved(Mark::Done) => ("✓", theme.green), - State::Resolved(Mark::Noop) => ("·", theme.dim), - State::Resolved(Mark::Merged) => ("·", theme.magenta), - }; - - let status = format!("[{word}]", word = row.word); - // PR rows nest one level under the step they belong to (the push) - // — four spaces — while bare step rows stay at the top level with - // two. - let plain = match row.kind { - RowKind::Step => format!(" {glyph} {word}", word = row.word), - RowKind::Pr => { - // The url already carries the PR number, so the title - // field is just the title. - let url = row.url.as_deref().unwrap_or(""); - let title = &row.title; - // Aligned (live view): pad status, the SHA detail, and url - // to fixed columns so titles line up even across rows where - // some carry a detail and some don't. The detail column is - // dropped entirely when no row has one. - if let Some((status_w, url_w, detail_w)) = cols { - if detail_w > 0 { - let detail = row.detail.as_deref().unwrap_or(""); - format!( - " {glyph} {status:<status_w$} {detail:<detail_w$} {url:<url_w$} {title}" - ) - } else { - format!(" {glyph} {status:<status_w$} {url:<url_w$} {title}") - } - } else { - // Tab-separated (piped transcript): always four fields — - // status, the SHA detail (empty when this row has none), - // url, title — so `cut -f` columns stay fixed and a - // detail-less row (a skip or an orphan delete) doesn't - // shift its url a field left of the create/update rows it - // streams beside. Mirrors the aligned view's reserved - // detail column. - let detail = row.detail.as_deref().unwrap_or(""); - format!(" {glyph} {status}\t{detail}\t{url}\t{title}") - } - } - }; - let plain = truncate_display(&plain, width); - - if !theme.enabled { - return plain; - } - // Color the glyph everywhere, and on PR rows the `[status]` tag - // (state color) and the SHA `detail` (dim). They sit ahead of the - // truncatable title, so first-match replaces are safe. - let mut out = plain.replacen( - glyph, - &format!("{glyph_style}{glyph}{reset}", reset = theme.reset), - 1, - ); - if row.kind == RowKind::Pr { - out = out.replacen( - &status, - &format!("{glyph_style}{status}{reset}", reset = theme.reset), - 1, - ); - if let Some(detail) = row.detail.as_deref() - && !detail.is_empty() - && out.contains(detail) - { - out = out.replacen( - detail, - &format!("{dim}{detail}{reset}", dim = theme.dim, reset = theme.reset), - 1, - ); - } - } - out -} - -/// Display width of `s` in terminal columns: each tab expands to the -/// next multiple of 8, and every other char counts at its Unicode -/// display width (CJK/emoji are 2, combining marks 0). -fn display_width(s: &str) -> usize { - s.chars().fold(0, |col, ch| { - if ch == '\t' { - (col / 8 + 1) * 8 - } else { - col + UnicodeWidthChar::width(ch).unwrap_or(0) - } - }) -} - -/// Truncate `s` to at most `max` display columns (tab-aware, -/// Unicode-width-aware), with a trailing `…` when shortened, so it -/// occupies exactly one terminal line. -fn truncate_display(s: &str, max: usize) -> String { - if display_width(s) <= max { - return s.to_string(); - } - let budget = max.saturating_sub(1); // leave a column for the `…` - let mut col = 0; - let mut out = String::new(); - for ch in s.chars() { - let next = if ch == '\t' { - (col / 8 + 1) * 8 - } else { - col + UnicodeWidthChar::width(ch).unwrap_or(0) - }; - if next > budget { - break; - } - out.push(ch); - col = next; - } - out.push('…'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pr_row(title: &str, word: &str, state: State, url: Option<&str>) -> Row { - Row { - kind: RowKind::Pr, - title: title.to_string(), - word: word.to_string(), - detail: None, - url: url.map(str::to_string), - state, - } - } - - fn step_row(word: &str, state: State) -> Row { - Row { - kind: RowKind::Step, - title: String::new(), - word: word.to_string(), - detail: None, - url: None, - state, - } - } - - fn plain() -> Theme { - Theme::new(false) - } - - fn colored() -> Theme { - Theme::new(true) - } - - #[test] - fn resolved_pr_row_reserves_a_blank_detail_field() { - // Tab mode always emits status, detail, url, title; a row with - // no SHA detail keeps a blank field so its url stays in the - // same `cut -f` column as the create/update rows beside it. - let r = pr_row( - "fix(stack): restore prefix-match", - "updated", - State::Resolved(Mark::Done), - Some("https://example/pull/1618"), - ); - assert_eq!( - render_row(&r, 0, usize::MAX, &plain(), None), - " ✓ [updated]\t\thttps://example/pull/1618\tfix(stack): restore prefix-match" - ); - } - - #[test] - fn step_row_without_title_is_glyph_and_label_only() { - let r = step_row("fetching trunk", State::Active); - assert_eq!( - render_row(&r, 0, usize::MAX, &plain(), None), - format!(" {} fetching trunk", FRAMES[0]) - ); - } - - #[test] - fn pending_pr_row_has_empty_detail_and_url_fields() { - let r = pr_row("title", "queued", State::Pending, None); - assert_eq!( - render_row(&r, 0, usize::MAX, &plain(), None), - " ◦ [queued]\t\t\ttitle" - ); - } - - #[test] - fn empty_title_pr_row_stays_a_pr_row() { - // A legitimately empty PR title must not collapse into a - // step row (which would mis-indent and drop alignment). - let r = pr_row("", "queued", State::Pending, None); - let line = render_row(&r, 0, usize::MAX, &plain(), None); - assert!(line.starts_with(" ◦ [queued]"), "got: {line}"); - } - - #[test] - fn active_row_shows_current_spinner_frame() { - let r = pr_row("t", "updating", State::Active, None); - assert!( - render_row(&r, 2, usize::MAX, &plain(), None) - .starts_with(&format!(" {} ", FRAMES[2])) - ); - } - - #[test] - fn create_row_without_url_renders_just_the_title() { - let r = pr_row("new thing", "creating", State::Active, None); - let line = render_row(&r, 0, usize::MAX, &plain(), None); - assert!(!line.contains('#'), "got: {line}"); - assert!(line.ends_with("new thing"), "got: {line}"); - } - - #[test] - fn detail_segment_renders_between_status_and_url() { - let mut r = pr_row("t", "updated", State::Resolved(Mark::Done), Some("u")); - r.detail = Some("old7→new7".to_string()); - let line = render_row(&r, 0, usize::MAX, &plain(), None); - assert_eq!(line, " ✓ [updated]\told7→new7\tu\tt"); - } - - #[test] - fn tab_mode_keeps_url_in_a_fixed_field_with_or_without_detail() { - // The reported regression: a created row carries a SHA detail, - // the orphan-delete row beside it carries none. Tab mode - // reserves the detail field on both, so the url stays the same - // `cut -f` field (field 3) rather than sliding a field left on - // the detail-less row. - let created = { - let mut r = pr_row("t", "created", State::Resolved(Mark::Done), Some("u1645")); - r.detail = Some("a649c67".to_string()); - r - }; - let deleted = pr_row("t", "deleted", State::Resolved(Mark::Noop), Some("u1644")); - let fields = |r: &Row| { - render_row(r, 0, usize::MAX, &plain(), None) - .split('\t') - .map(str::to_string) - .collect::<Vec<_>>() - }; - let fc = fields(&created); - let fd = fields(&deleted); - assert_eq!(fc.len(), 4, "created: {fc:?}"); - assert_eq!(fd.len(), 4, "deleted: {fd:?}"); - assert_eq!((fc[1].as_str(), fc[2].as_str()), ("a649c67", "u1645")); - assert_eq!((fd[1].as_str(), fd[2].as_str()), ("", "u1644")); - } - - #[test] - fn detail_segment_is_dimmed_when_colored() { - let mut r = pr_row("t", "created", State::Resolved(Mark::Done), None); - r.detail = Some("new1234".to_string()); - let line = render_row(&r, 0, usize::MAX, &colored(), Some((9, 0, 7))); - let theme = colored(); - assert!( - line.contains(&format!("{}new1234{}", theme.dim, theme.reset)), - "got: {line}" - ); - } - - #[test] - fn long_row_is_truncated_to_one_line() { - let r = pr_row( - "a very long pull request title that will not fit", - "updated", - State::Resolved(Mark::Done), - Some("https://example/pull/42"), - ); - let line = render_row(&r, 0, 40, &plain(), None); - assert!( - display_width(&line) <= 40, - "width {}: {line}", - display_width(&line) - ); - assert!(line.contains('…'), "got: {line}"); - } - - #[test] - fn color_wraps_glyph_and_status_tag_but_not_title() { - let r = pr_row("plaintitle", "updated", State::Resolved(Mark::Done), None); - let theme = colored(); - let line = render_row(&r, 0, usize::MAX, &theme, None); - assert!( - line.starts_with(&format!(" {}✓{}", theme.green, theme.reset)), - "glyph green: {line}" - ); - assert!( - line.contains(&format!("{}[updated]{}", theme.green, theme.reset)), - "status green: {line}" - ); - assert!(line.contains("plaintitle"), "title plain: {line}"); - } - - #[test] - fn noop_row_uses_dim_dot() { - let r = pr_row("t", "skipped", State::Resolved(Mark::Noop), None); - assert!(render_row(&r, 0, usize::MAX, &plain(), None).starts_with(" · ")); - } - - #[test] - fn merged_mark_is_magenta_like_github() { - let r = pr_row("t", "merged", State::Resolved(Mark::Merged), None); - let theme = colored(); - let line = render_row(&r, 0, usize::MAX, &theme, None); - // The merged glyph is the magenta dot. - assert!( - line.starts_with(&format!(" {}·{}", theme.magenta, theme.reset)), - "got: {line}" - ); - assert!( - line.contains(&format!("{}[merged]{}", theme.magenta, theme.reset)), - "got: {line}" - ); - } - - #[test] - fn aligned_cols_line_up_titles_across_rows() { - // Different status widths and url lengths; the title column - // must still start at the same display column in both rows. - let a = pr_row( - "title-a", - "updated", - State::Resolved(Mark::Done), - Some("u1"), - ); - let b = pr_row( - "title-b", - "up-to-date", - State::Resolved(Mark::Noop), - Some("longer-url"), - ); - let cols = Some(("up-to-date".len() + 2, "longer-url".len(), 0)); - let col_of = |s: &str, needle: &str| s[..s.find(needle).unwrap()].chars().count(); - let la = render_row(&a, 0, usize::MAX, &plain(), cols); - let lb = render_row(&b, 0, usize::MAX, &plain(), cols); - assert_eq!( - col_of(&la, "title-a"), - col_of(&lb, "title-b"), - "a: {la:?}\nb: {lb:?}" - ); - } - - #[test] - fn aligned_cols_line_up_titles_with_mixed_detail() { - // One row carries a SHA detail, the other doesn't; the title - // column must still start at the same display column — the - // detail is a padded column, not an inline splice. - let with = { - let mut r = pr_row( - "title-a", - "updated", - State::Resolved(Mark::Done), - Some("u1"), - ); - r.detail = Some("old7→new7".to_string()); - r - }; - let without = pr_row( - "title-b", - "up-to-date", - State::Resolved(Mark::Noop), - Some("u2"), - ); - let cols = Some(("up-to-date".len() + 2, 2, "old7→new7".chars().count())); - let col_of = |s: &str, needle: &str| s[..s.find(needle).unwrap()].chars().count(); - let la = render_row(&with, 0, usize::MAX, &plain(), cols); - let lb = render_row(&without, 0, usize::MAX, &plain(), cols); - assert_eq!( - col_of(&la, "title-a"), - col_of(&lb, "title-b"), - "a: {la:?}\nb: {lb:?}" - ); - } - - #[test] - fn display_width_expands_tabs_to_eight_column_stops() { - assert_eq!(display_width("ab\tc"), 9); - assert_eq!(display_width("\t"), 8); - assert_eq!(display_width("plain"), 5); - } - - #[test] - fn display_width_counts_wide_glyphs_as_two_columns() { - // CJK ideographs are double-width; naive char-count would - // under-measure and let a too-wide line slip past truncation. - let cjk = "日本語"; // 3 chars, 6 columns - assert_eq!(cjk.chars().count(), 3); - assert_eq!(display_width(cjk), 6); - assert!(display_width(cjk) > cjk.chars().count()); - } - - #[test] - fn truncate_respects_wide_glyph_budget() { - let cjk = "日本語日本語日本語"; // 9 chars, 18 columns - let out = truncate_display(cjk, 8); - assert!( - display_width(&out) <= 8, - "width {}: {out}", - display_width(&out) - ); - } - - #[test] - fn plain_mode_records_transcript_on_resolve_only() { - let mut p = Progress::with_mode(false, plain(), 80, 24); - let idx = p.add_pr("title", "queued", None, None); - assert!(p.transcript.is_empty(), "pending must not record"); - p.resolve(idx, Mark::Done, Some("updated")); - assert_eq!(p.transcript, vec![" ✓ [updated]\t\t\ttitle".to_string()]); - } - - #[test] - fn seal_resets_rows_and_draw_state() { - let mut p = Progress::with_mode(false, plain(), 80, 24); - let _ = p.add("fetching"); - p.seal(); - assert!(p.rows.is_empty()); - assert_eq!(p.drawn, 0); - assert!(p.cols.is_none()); - // A fresh row starts at index 0 again. - assert_eq!(p.add_pr("t", "queued", None, None), 0); - } - - #[test] - fn set_url_fills_a_created_rows_url() { - let mut p = Progress::with_mode(false, plain(), 80, 24); - let idx = p.add_pr("new pr", "queued", None, None); - p.set_url(idx, Some("https://example/pull/1630".to_string())); - p.resolve(idx, Mark::Done, Some("created")); - assert_eq!( - p.transcript, - vec![" ✓ [created]\t\thttps://example/pull/1630\tnew pr".to_string()] - ); - } - - #[test] - fn cols_cache_tracks_widest_row() { - let mut p = Progress::with_mode(false, plain(), 80, 24); - p.add_pr("a", "queued", None, Some("short".into())); - assert_eq!(p.cols, Some(("queued".len() + 2, "short".len(), 0))); - p.add_pr("b", "up-to-date", None, Some("longer-url".into())); - assert_eq!( - p.cols, - Some(("up-to-date".len() + 2, "longer-url".len(), 0)) - ); - } - - #[test] - fn overflow_degrades_without_oversized_cursor_up() { - // A row count that exceeds a tiny forced height must trip the - // streaming fallback rather than emit a cursor-up larger than - // the viewport (which would corrupt scrollback). - let mut p = Progress::with_mode(true, plain(), 80, 3); - // height 3 ⇒ block may hold at most 2 rows before overflow. - p.add("a"); - p.add("b"); - assert!(p.interactive, "still fits"); - p.add("c"); // would need 3 live rows in a 3-row terminal - assert!(!p.interactive, "must have degraded to streaming"); - // drawn is reset on degrade, so no later cursor-up can exceed - // the viewport. - assert_eq!(p.drawn, 0); - assert!( - p.drawn < p.height, - "cursor-up ({}) must stay within height ({})", - p.drawn, - p.height - ); - } -} diff --git a/crates/mergify-stack/src/push_helpers.rs b/crates/mergify-stack/src/push_helpers.rs deleted file mode 100644 index 56d758c4..00000000 --- a/crates/mergify-stack/src/push_helpers.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Pure helpers for the `stack push` orchestrator that don't fit -//! a domain module of their own: -//! -//! - [`format_pull_description`] — strip the `Change-Id:` and -//! stale `Depends-On:` lines from a commit message and append -//! a fresh `Depends-On:` when the orchestrator linked this -//! change to the previous PR in the stack. -//! - [`build_change_tasks`] — turn the planned `LocalChange` -//! list into the dependency graph the per-PR upserter walks: -//! each entry knows the index of the previous live change in -//! the stack (so its `Depends-On:` header can point at that -//! PR's number once it's known) and whether its own PR is -//! already known at start of the run. -//! -//! Ported from `mergify_cli/stack/push.py::{format_pull_description, -//! _build_change_tasks}`. - -use std::fmt::Write; -use std::sync::LazyLock; - -use regex::Regex; -use serde_json::Value; - -use crate::changes::{Action, LocalChange}; - -/// `Depends-On: #<number>` line the orchestrator appends to a PR -/// description to chain stacked PRs. Matches case-sensitive on -/// purpose — the live Mergify backend only acts on the exact -/// header form. -static DEPENDS_ON_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"Depends-On: \(#[0-9]*\)|Depends-On: #[0-9]*").unwrap()); - -/// `Change-Id: I…` trailer the stack tooling injects into every -/// commit message body. Mirrors `changes.py::CHANGEID_RE` — -/// matches the loose 40-char alphanumeric tail so a malformed -/// trailer still gets stripped (it would otherwise pollute the -/// rendered PR description). -static CHANGEID_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"Change-Id: I[0-9a-z]{40}").unwrap()); - -/// Render the PR description from a commit message. -/// -/// The transformation: -/// -/// 1. Strip the `Change-Id:` trailer — it's internal plumbing, -/// not something the reviewer should see. -/// 2. Strip any existing `Depends-On:` lines — the orchestrator -/// picked which PR this one depends on; stale ones from -/// earlier pushes get rewritten. -/// 3. Append a fresh `Depends-On: #<number>` when the previous -/// live change in the stack has a known PR number. -/// -/// `depends_on_number` is the PR number of the previous live -/// change in the stack (the orchestrator resolves this from -/// `build_change_tasks` + the per-task `pull_ready` signal). -/// Pass `None` for the bottom of the stack — no dependency to -/// chain on. -#[must_use] -pub fn format_pull_description(message: &str, depends_on_number: Option<u64>) -> String { - let mut out = CHANGEID_RE.replace_all(message, "").into_owned(); - // Match Python's `.rstrip("\n")` after each substitution — - // the Change-Id line typically has a trailing newline that - // would otherwise leave a blank line where the trailer used - // to be. - while out.ends_with('\n') { - out.pop(); - } - out = DEPENDS_ON_RE.replace_all(&out, "").into_owned(); - while out.ends_with('\n') { - out.pop(); - } - if let Some(number) = depends_on_number { - write!(out, "\n\nDepends-On: #{number}").expect("write to String never fails"); - } - out -} - -/// One row in the dependency graph the per-PR upserter walks. -/// `index` is the position in the original `local_changes` list -/// (so callers can recover the matching `LocalChange`); -/// `depends_on_index` is the index of the previous live change -/// in the stack — the one whose PR number this task's -/// `Depends-On:` header should chain to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TaskMeta { - pub index: usize, - pub depends_on_index: Option<usize>, - /// `true` when the change's PR is already known at the - /// start of the push (either pre-existing PR via `Update` - /// or `Skip*`, or no PR will ever exist via `SkipMerged`). - /// Mirrors `pull_ready.set()` being called immediately on - /// the Python side — downstream awaiters don't have to - /// block on this task. - pub pull_ready_at_start: bool, - /// PR payload available at task-build time. `None` for - /// `Create` actions (the PR doesn't exist yet), `Some` for - /// every other action that carries a `pull` field. - pub initial_pull: Option<Value>, -} - -/// Turn the planned `local_changes` into the task graph the -/// orchestrator walks. -/// -/// The graph is linear by construction: each task's -/// `depends_on_index` points at the *most recent* prior change -/// whose `change.pull` is set or whose action is `Create` — -/// i.e. anything that has (or will have) a PR. `SkipMerged` -/// entries that still carry a `pull` qualify and become the -/// anchor; the orchestrator walks past them when resolving the -/// `Depends-On:` header so the rendered chain points at the -/// next live PR. Locked-in to match Python (`if change.pull is -/// not None or action == "create"`). -#[must_use] -pub fn build_change_tasks(local_changes: &[LocalChange]) -> Vec<TaskMeta> { - let mut tasks: Vec<TaskMeta> = Vec::with_capacity(local_changes.len()); - let mut last_pull_index: Option<usize> = None; - - for (i, change) in local_changes.iter().enumerate() { - // pull_ready_at_start matches Python's - // `task.pull_ready.set()` early-returns: - // - action != Create && pull is Some → resolved - // (Update / SkipUpToDate / SkipMerged with a pull) - // - action ∉ {Create, Update} → no upsert will run, - // so nothing blocks on it - // - everything else (Create with no pull, Update with - // no pull) → not ready; the upserter will resolve it - let has_pull = change.pull.is_some(); - let action = change.action; - let pull_ready_at_start = match action { - Action::Create => false, - Action::Update => has_pull, - // Skip-* actions don't drive an upsert, so downstream - // awaiters don't need to block on them. - Action::SkipMerged - | Action::SkipUpToDate - | Action::SkipCreate - | Action::SkipNextOnly => true, - }; - - tasks.push(TaskMeta { - index: i, - depends_on_index: last_pull_index, - pull_ready_at_start, - initial_pull: if matches!(action, Action::Create) && !has_pull { - None - } else { - change.pull.clone() - }, - }); - - // Carry this index forward as the dependency anchor for - // the next change. Matches Python's `if change.pull is - // not None or action == "create"` — `SkipMerged` with a - // pull *does* qualify; the orchestrator walks past it - // when rendering `Depends-On:` so the header still - // points at the next live PR, not the merged one. - if change.pull.is_some() || matches!(action, Action::Create) { - last_pull_index = Some(i); - } - } - - tasks -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn change(action: Action, pull: Option<Value>) -> LocalChange { - LocalChange { - commit_sha: "deadbeef".to_string(), - title: "t".to_string(), - message: "m".to_string(), - change_id: "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - action, - pull, - note: String::new(), - } - } - - #[test] - fn format_strips_change_id_trailer() { - let msg = "feat: x\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"; - assert_eq!(format_pull_description(msg, None), "feat: x"); - } - - #[test] - fn format_strips_stale_depends_on_lines_and_preserves_internal_blanks() { - // Existing `Depends-On: #N` is stripped — the - // orchestrator owns the chaining, so a stale value left - // over from a previous push would otherwise point at - // the wrong PR. Internal whitespace around the trailer - // is **preserved** byte-for-byte (Python's `rstrip("\n")` - // only touches the *trailing* newline run, not the - // interior ones); spot-checked against the Python - // implementation. - let msg = "feat: x\n\nDepends-On: #999\n\nbody"; - assert_eq!( - format_pull_description(msg, None), - // Python returns `'feat: x\n\n\n\nbody'` — the - // double-blank gap where the trailer used to be is - // intentional. - "feat: x\n\n\n\nbody", - ); - } - - #[test] - fn format_appends_depends_on_when_predecessor_is_known() { - let msg = "feat: x\n\nbody\n\nChange-Id: Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n"; - let out = format_pull_description(msg, Some(42)); - assert!(out.ends_with("\n\nDepends-On: #42")); - // And the Change-Id is still gone. - assert!(!out.contains("Change-Id")); - } - - #[test] - fn format_preserves_body_when_no_trailers_present() { - let out = format_pull_description("feat: x\n\nbody", None); - assert_eq!(out, "feat: x\n\nbody"); - } - - #[test] - fn build_assigns_depends_on_anchors_in_order() { - // Three Creates → each one's depends_on_index is the - // previous; first one has no predecessor. - let locals = vec![ - change(Action::Create, None), - change(Action::Create, None), - change(Action::Create, None), - ]; - let tasks = build_change_tasks(&locals); - assert_eq!(tasks[0].depends_on_index, None); - assert_eq!(tasks[1].depends_on_index, Some(0)); - assert_eq!(tasks[2].depends_on_index, Some(1)); - } - - #[test] - fn build_marks_creates_as_not_ready_and_updates_as_ready_with_pull() { - // pull_ready_at_start gating mirrors Python's - // pull_ready.set() early-returns — Update with a known - // pull doesn't need the upserter to run before - // downstream tasks can read it. - let locals = vec![ - change(Action::Create, None), - change(Action::Update, Some(json!({"number": 2}))), - change(Action::SkipUpToDate, Some(json!({"number": 3}))), - change(Action::SkipMerged, Some(json!({"number": 4}))), - ]; - let tasks = build_change_tasks(&locals); - assert!(!tasks[0].pull_ready_at_start); - assert!(tasks[1].pull_ready_at_start); - assert!(tasks[2].pull_ready_at_start); - assert!(tasks[3].pull_ready_at_start); - } - - #[test] - fn build_skip_merged_with_pull_anchors_next_task_dependency() { - // `Depends-On: #merged-pr` would be meaningless — the - // commit is on trunk, not behind a PR. Anchor must skip - // ahead to the next live change. - let locals = vec![ - change(Action::Update, Some(json!({"number": 1}))), - change(Action::SkipMerged, Some(json!({"number": 99}))), - change(Action::Create, None), - ]; - let tasks = build_change_tasks(&locals); - // The SkipMerged task is part of the carry-forward only - // because the Python `if change.pull is not None or - // action == "create"` clause says so — its index *does* - // become the anchor for the next task, matching the - // Python behaviour exactly. The pull number passed via - // Depends-On is just the merged PR's number, which the - // orchestrator then walks past when resolving the - // header (skip-merged tasks set pull_ready immediately - // with the merged PR). - // - // Lock in the Python-compatible behaviour so a future - // refactor doesn't silently change which PR the next - // task's Depends-On points at. - assert_eq!(tasks[2].depends_on_index, Some(1)); - } - - #[test] - fn build_initial_pull_carries_through_for_non_create_actions() { - let pull = json!({"number": 42}); - let locals = vec![change(Action::Update, Some(pull.clone()))]; - let tasks = build_change_tasks(&locals); - assert_eq!(tasks[0].initial_pull.as_ref(), Some(&pull)); - } -} diff --git a/crates/mergify-stack/src/rebase_log.rs b/crates/mergify-stack/src/rebase_log.rs deleted file mode 100644 index 18c1d753..00000000 --- a/crates/mergify-stack/src/rebase_log.rs +++ /dev/null @@ -1,315 +0,0 @@ -//! Render the log lines `stack push` emits to explain its -//! rebase / no-rebase choice. -//! -//! The body of every line carries the same information surfaced -//! by [`crate::approvals::RebaseDecision`] — *why* the orchestrator -//! decided to rebase or skip — phrased for a human reader. Three -//! entry points cover the three points in the push flow that -//! need to log: -//! -//! - [`rebase_performed`] — after a real rebase ran. -//! - [`rebase_skipped`] — after the orchestrator decided not -//! to rebase. Returns `None` for the cases where there's -//! nothing to log (no skip happened). -//! - [`rebase_dry_run`] — the `--dry-run` path; emits the -//! would-have-rebased / would-have-skipped narration as a -//! list of lines (the approvals-skip case fans out one row -//! per approved PR). -//! -//! Pure formatters: they return strings instead of touching a -//! console so the caller decides how to print them and so the -//! tests don't have to mock a logger. Ported from -//! `mergify_cli/stack/push.py::{_log_rebase_performed, -//! _log_rebase_skipped, _log_rebase_dry_run}`. - -use serde_json::Value; - -use crate::approvals::{RebaseDecision, RebaseReason}; - -/// Log line for a completed rebase. `merged_count` is the number -/// of commits the sync step dropped (from -/// `sync_status.merged.len()` on the Python side); 0 elides the -/// `(dropped N merged commit(s))` clause entirely. -#[must_use] -pub fn rebase_performed( - dest_branch: &str, - remote: &str, - base_branch: &str, - merged_count: usize, - decision: &RebaseDecision, -) -> String { - let dropped = if merged_count > 0 { - format!(" (dropped {merged_count} merged commit(s))") - } else { - String::new() - }; - let prefix = format!("branch `{dest_branch}` rebased on `{remote}/{base_branch}`{dropped}"); - match decision.reason { - RebaseReason::ConflictOverride => { - let numbers = pull_numbers_csv(&decision.approved_pulls); - format!( - "{prefix} (bottom PR has conflicts; approvals on PR(s) {numbers} may be dismissed)", - ) - } - RebaseReason::Forced => format!("{prefix} (--force-rebase; approvals may be dismissed)"), - _ => prefix, - } -} - -/// Log line for a skipped rebase. Returns `None` when the -/// decision doesn't represent a skip (i.e. the rebase actually -/// happened) — the orchestrator just doesn't log anything in -/// those cases. -#[must_use] -pub fn rebase_skipped(dest_branch: &str, decision: &RebaseDecision) -> Option<String> { - match decision.reason { - RebaseReason::ExplicitSkip => Some(format!( - "branch `{dest_branch}` rebase skipped (--skip-rebase)" - )), - RebaseReason::SkippedForApprovals => { - let n = decision.approved_pulls.len(); - let plural = if n == 1 { "" } else { "s" }; - let verb = if n == 1 { "has" } else { "have" }; - Some(format!( - "branch `{dest_branch}` rebase skipped: {n} PR{plural} {verb} approvals \ - (use --force-rebase to rebase anyway)", - )) - } - // The rebase actually happened — nothing to log here; - // `rebase_performed` handles those. - _ => None, - } -} - -/// Multi-line narration for the `--dry-run` path. Returns the -/// log lines in the order they should be printed. Empty vec -/// means "nothing to say" — happens on `NoApprovals` when the -/// branch isn't behind, which is the no-op rebase case. -#[must_use] -pub fn rebase_dry_run( - dest_branch: &str, - remote: &str, - base_branch: &str, - commits_behind: u32, - decision: &RebaseDecision, -) -> Vec<String> { - match decision.reason { - RebaseReason::ExplicitSkip => { - vec![format!( - "branch `{dest_branch}` rebase skipped (--skip-rebase)" - )] - } - RebaseReason::SkippedForApprovals => { - let n = decision.approved_pulls.len(); - let plural = if n == 1 { "" } else { "s" }; - let mut lines = vec![format!( - "branch `{dest_branch}` rebase would be skipped: \ - approvals detected on {n} PR{plural}", - )]; - for pull in &decision.approved_pulls { - let number = pull.get("number").and_then(Value::as_u64).unwrap_or(0); - let title = pull.get("title").and_then(Value::as_str).unwrap_or(""); - lines.push(format!(" - PR #{number} — \"{title}\"")); - } - lines.push(" Use --force-rebase to rebase anyway.".to_string()); - lines - } - RebaseReason::ConflictOverride => { - let numbers = pull_numbers_csv(&decision.approved_pulls); - vec![format!( - "branch `{dest_branch}` would be rebased on `{remote}/{base_branch}` \ - (bottom PR has conflicts; approvals on PR(s) {numbers} would be dismissed)", - )] - } - RebaseReason::Forced => vec![format!( - "branch `{dest_branch}` would be rebased on `{remote}/{base_branch}` \ - (--force-rebase; approvals may be dismissed)", - )], - RebaseReason::NoApprovals => { - // Match Python's "only warn if behind" — silent - // no-op when the branch is already up-to-date. - if commits_behind == 0 { - return Vec::new(); - } - let plural = if commits_behind == 1 { - "commit" - } else { - "commits" - }; - vec![format!( - "branch `{dest_branch}` is behind `{remote}/{base_branch}` \ - by {commits_behind} {plural}, commit SHAs will differ after rebase", - )] - } - } -} - -fn pull_numbers_csv(pulls: &[Value]) -> String { - pulls - .iter() - .filter_map(|p| p.get("number").and_then(Value::as_u64)) - .map(|n| format!("#{n}")) - .collect::<Vec<_>>() - .join(", ") -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn decision(reason: RebaseReason, approved: Vec<Value>) -> RebaseDecision { - RebaseDecision { - should_rebase: matches!( - reason, - RebaseReason::Forced | RebaseReason::ConflictOverride | RebaseReason::NoApprovals, - ), - reason, - approved_pulls: approved, - } - } - - #[test] - fn performed_no_approvals_omits_qualifier() { - let d = decision(RebaseReason::NoApprovals, Vec::new()); - assert_eq!( - rebase_performed("feat/x", "origin", "main", 0, &d), - "branch `feat/x` rebased on `origin/main`", - ); - } - - #[test] - fn performed_with_merged_drops_count_appears_in_prefix() { - // The dropped-commits clause sits between the rebase - // anchor and the optional reason — order matters because - // Python pins this exact phrasing in the CLI output the - // user reads. - let d = decision(RebaseReason::NoApprovals, Vec::new()); - assert_eq!( - rebase_performed("feat/x", "origin", "main", 2, &d), - "branch `feat/x` rebased on `origin/main` (dropped 2 merged commit(s))", - ); - } - - #[test] - fn performed_conflict_override_lists_dismissed_approvals() { - let d = decision( - RebaseReason::ConflictOverride, - vec![json!({"number": 12}), json!({"number": 34})], - ); - let out = rebase_performed("feat/x", "origin", "main", 0, &d); - assert!(out.contains("bottom PR has conflicts")); - assert!(out.contains("#12, #34")); - } - - #[test] - fn performed_forced_mentions_force_rebase_flag() { - let d = decision(RebaseReason::Forced, Vec::new()); - assert_eq!( - rebase_performed("feat/x", "origin", "main", 0, &d), - "branch `feat/x` rebased on `origin/main` (--force-rebase; approvals may be dismissed)", - ); - } - - #[test] - fn skipped_explicit_skip_line_matches_python() { - let d = decision(RebaseReason::ExplicitSkip, Vec::new()); - assert_eq!( - rebase_skipped("feat/x", &d), - Some("branch `feat/x` rebase skipped (--skip-rebase)".to_string()), - ); - } - - #[test] - fn skipped_for_approvals_singular_grammar_for_one_pr() { - let d = decision( - RebaseReason::SkippedForApprovals, - vec![json!({"number": 1})], - ); - // 1 PR has approvals — singular verb + no plural marker. - // Regress and a user sees "1 PRs have approvals" which - // grates. - assert_eq!( - rebase_skipped("feat/x", &d).unwrap(), - "branch `feat/x` rebase skipped: 1 PR has approvals \ - (use --force-rebase to rebase anyway)", - ); - } - - #[test] - fn skipped_for_approvals_plural_grammar_for_multiple_prs() { - let d = decision( - RebaseReason::SkippedForApprovals, - vec![json!({"number": 1}), json!({"number": 2})], - ); - assert_eq!( - rebase_skipped("feat/x", &d).unwrap(), - "branch `feat/x` rebase skipped: 2 PRs have approvals \ - (use --force-rebase to rebase anyway)", - ); - } - - #[test] - fn skipped_returns_none_for_non_skip_decisions() { - // The rebase actually ran — there's nothing to "skip - // line" log; the caller printed `rebase_performed` - // instead. - for r in [ - RebaseReason::Forced, - RebaseReason::ConflictOverride, - RebaseReason::NoApprovals, - ] { - assert!(rebase_skipped("feat/x", &decision(r, Vec::new())).is_none()); - } - } - - #[test] - fn dry_run_skipped_for_approvals_fans_out_one_line_per_pull() { - // The orange header + per-PR rows + trailing - // `Use --force-rebase` footer are the three pieces the - // Python CLI prints; a regression would lose either the - // titles or the trailing hint. - let d = decision( - RebaseReason::SkippedForApprovals, - vec![ - json!({"number": 1, "title": "feat: a"}), - json!({"number": 2, "title": "feat: b"}), - ], - ); - let lines = rebase_dry_run("feat/x", "origin", "main", 5, &d); - assert_eq!(lines.len(), 4, "header + 2 rows + footer"); - assert!(lines[0].contains("approvals detected on 2 PRs")); - assert_eq!(lines[1], " - PR #1 — \"feat: a\""); - assert_eq!(lines[2], " - PR #2 — \"feat: b\""); - assert_eq!(lines[3], " Use --force-rebase to rebase anyway."); - } - - #[test] - fn dry_run_no_approvals_is_silent_when_branch_is_up_to_date() { - // Match Python exactly — no orange "behind by 0 commits" - // spam when the branch is current. A previous regression - // emitted a line even at 0 commits behind. - let d = decision(RebaseReason::NoApprovals, Vec::new()); - assert!(rebase_dry_run("feat/x", "origin", "main", 0, &d).is_empty()); - } - - #[test] - fn dry_run_no_approvals_warns_when_branch_is_behind() { - let d = decision(RebaseReason::NoApprovals, Vec::new()); - let lines = rebase_dry_run("feat/x", "origin", "main", 3, &d); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("behind `origin/main` by 3 commits")); - // Singular grammar for `commits_behind == 1`. - let lines = rebase_dry_run("feat/x", "origin", "main", 1, &d); - assert!(lines[0].contains("behind `origin/main` by 1 commit")); - } - - #[test] - fn dry_run_forced_announces_intent_to_rebase() { - let d = decision(RebaseReason::Forced, Vec::new()); - let lines = rebase_dry_run("feat/x", "origin", "main", 0, &d); - assert_eq!(lines.len(), 1); - assert!(lines[0].contains("would be rebased")); - assert!(lines[0].contains("--force-rebase")); - } -} diff --git a/crates/mergify-stack/src/rebase_todo.rs b/crates/mergify-stack/src/rebase_todo.rs deleted file mode 100644 index a1930b37..00000000 --- a/crates/mergify-stack/src/rebase_todo.rs +++ /dev/null @@ -1,846 +0,0 @@ -//! Rewrite the rebase-todo file `git rebase -i` would otherwise -//! open in `$GIT_EDITOR`. -//! -//! `mergify stack {edit,drop,reword,fixup,reorder,move,squash}` -//! all share the same pattern: spawn `git rebase -i <base>` with -//! `GIT_SEQUENCE_EDITOR` pointed at this binary; the binary's -//! `_internal rebase-todo-rewrite` subcommand reads the todo file, -//! applies one of the [`Action`] transformations defined here, and -//! writes it back. The pure transformer in this module keeps the -//! filesystem and process-spawning concerns out, so the parser is -//! exhaustively unit-testable without spawning git. -//! -//! Todo-line shape (from `git-rebase(1)`): -//! `<verb> <sha> <subject>`, e.g. `pick 1a2b3c4d feat: add foo`. -//! Blank lines and lines starting with `#` are kept verbatim -//! (they're comments / git-managed annotations and round-trip -//! intact in the rewritten file). - -use mergify_core::CliError; - -/// What [`rewrite`] should do with the targeted commits. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Action { - /// Mark `<sha>` as `edit` so the rebase pauses on that commit - /// for an `git commit --amend` + `git rebase --continue` loop. - /// Matching is prefix-based: a todo SHA either starts with - /// `<sha>` or `<sha>` starts with the todo SHA. - Edit { sha: String }, - /// Remove the `pick` lines for every SHA in `shas`. Each SHA - /// must match exactly one `pick` line; missing matches surface - /// as [`CliError::InvalidState`] so the rebase doesn't quietly - /// proceed with a subset of the intended drops. - Drop { shas: Vec<String> }, - /// Rewrite the `pick` lines for every SHA in `shas` as - /// `fixup`. Same partial-match guard as [`Action::Drop`]. - Fixup { shas: Vec<String> }, - /// Mark `<sha>` as `reword` so git stops at that commit and - /// runs `git commit --amend`, opening `$GIT_EDITOR` for the - /// message rewrite. Interactive; pair with an `-m` argument - /// on the orchestrator to stay non-interactive (it'll use - /// [`Action::ExecAfter`] instead). - Reword { sha: String }, - /// Inject an `exec <command>` line right after the matching - /// `pick` line. Used to run `git commit --amend -F <file>` - /// while HEAD still points at the target commit (non- - /// interactive reword), and as one half of `stack squash`'s - /// custom-message path. - ExecAfter { sha: String, command: String }, - /// Reorder the `pick` lines to the given sequence (other lines - /// — comments, blank lines, `exec` etc. — are appended at the - /// end). Every SHA in `ordered_shas` must match exactly one - /// `pick` line and the count must equal the number of picks in - /// the todo. - Reorder { ordered_shas: Vec<String> }, - /// Combined reorder + fixup + optional exec-after used by - /// `stack squash`. Each SHA in `ordered_shas` matches exactly - /// one pick line; those listed in `fixup_shas` get their verb - /// rewritten to `fixup`. If `exec_after_sha` and `exec_command` - /// are both set, an `exec <command>` line is inserted right - /// after the matching todo entry. - Squash { - ordered_shas: Vec<String>, - fixup_shas: Vec<String>, - exec_after_sha: Option<String>, - exec_command: Option<String>, - }, -} - -/// Apply `action` to `todo` and return the rewritten contents. -/// Errors when the action doesn't match anything in `todo` -/// ([`CliError::InvalidState`]) so we fail loud instead of letting -/// the rebase proceed unchanged. -pub fn rewrite(todo: &str, action: &Action) -> Result<String, CliError> { - match action { - Action::Edit { sha } => rewrite_edit(todo, sha), - Action::Drop { shas } => rewrite_drop(todo, shas), - Action::Fixup { shas } => rewrite_replace_verb(todo, shas, "fixup"), - Action::Reword { sha } => rewrite_replace_verb(todo, std::slice::from_ref(sha), "reword"), - Action::ExecAfter { sha, command } => rewrite_exec_after(todo, sha, command), - Action::Reorder { ordered_shas } => rewrite_reorder(todo, ordered_shas), - Action::Squash { - ordered_shas, - fixup_shas, - exec_after_sha, - exec_command, - } => rewrite_squash( - todo, - ordered_shas, - fixup_shas, - exec_after_sha.as_deref(), - exec_command.as_deref(), - ), - } -} - -fn rewrite_edit(todo: &str, target: &str) -> Result<String, CliError> { - let mut matched = false; - let mut out = String::with_capacity(todo.len()); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(rest) = trimmed.strip_prefix("pick ") { - // `<sha> <subject>` — pull the SHA off, leave the rest - // alone so subjects with spaces survive. - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - if sha_matches(sha, target) { - out.push_str("edit "); - out.push_str(rest); - // Preserve the original terminator (`\n` or - // `\r\n`) — Windows checkouts can deliver CRLF. - let terminator = &line[trimmed.len()..]; - out.push_str(terminator); - matched = true; - continue; - } - } - out.push_str(line); - } - if !matched { - return Err(CliError::InvalidState(format!( - "rebase-todo has no `pick` line for {target}; aborting so the rebase doesn't run unchanged" - ))); - } - Ok(out) -} - -fn rewrite_drop(todo: &str, targets: &[String]) -> Result<String, CliError> { - if targets.is_empty() { - return Err(CliError::InvalidState( - "rebase-todo drop: no commits to drop".to_string(), - )); - } - let mut matched: Vec<bool> = vec![false; targets.len()]; - let mut out = String::with_capacity(todo.len()); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(rest) = trimmed.strip_prefix("pick ") { - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - if let Some(idx) = targets.iter().position(|t| sha_matches(sha, t)) { - matched[idx] = true; - // Skip this line — drop semantics is "remove the - // pick line", same as deleting it in - // `git rebase -i`. - continue; - } - } - out.push_str(line); - } - let missing: Vec<&str> = targets - .iter() - .zip(matched.iter()) - .filter_map(|(t, &m)| (!m).then_some(t.as_str())) - .collect(); - if !missing.is_empty() { - return Err(CliError::InvalidState(format!( - "rebase-todo has no `pick` line for: {}; aborting so the rebase doesn't run with a partial drop", - missing.join(", ") - ))); - } - Ok(out) -} - -/// Generalised `pick → <verb>` rewriter for actions that swap the -/// command keyword instead of removing the line (today: `fixup`; -/// `reword` and friends slot in the same way). Every target must -/// match exactly one `pick` line — missing matches surface as -/// [`CliError::InvalidState`] so the rebase doesn't run with only -/// a subset of the intended changes, and ambiguous matches (a -/// SHA prefix that resolves to multiple `pick` lines) error out -/// for the same reason: silently rewriting two commits when the -/// caller meant one would corrupt the rebase. -fn rewrite_replace_verb(todo: &str, targets: &[String], verb: &str) -> Result<String, CliError> { - if targets.is_empty() { - return Err(CliError::InvalidState(format!( - "rebase-todo {verb}: no commits to {verb}" - ))); - } - let mut match_count: Vec<usize> = vec![0; targets.len()]; - let mut out = String::with_capacity(todo.len()); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(rest) = trimmed.strip_prefix("pick ") { - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - if let Some(idx) = targets.iter().position(|t| sha_matches(sha, t)) { - match_count[idx] += 1; - out.push_str(verb); - out.push(' '); - out.push_str(rest); - let terminator = &line[trimmed.len()..]; - out.push_str(terminator); - continue; - } - } - out.push_str(line); - } - let ambiguous: Vec<&str> = targets - .iter() - .zip(match_count.iter()) - .filter_map(|(t, &n)| (n > 1).then_some(t.as_str())) - .collect(); - if !ambiguous.is_empty() { - return Err(CliError::InvalidState(format!( - "rebase-todo {verb}: target(s) {} matched multiple `pick` lines; pass a longer SHA prefix", - ambiguous.join(", "), - ))); - } - let matched: Vec<bool> = match_count.iter().map(|&n| n > 0).collect(); - let missing: Vec<&str> = targets - .iter() - .zip(matched.iter()) - .filter_map(|(t, &m)| (!m).then_some(t.as_str())) - .collect(); - if !missing.is_empty() { - return Err(CliError::InvalidState(format!( - "rebase-todo has no `pick` line for: {}; aborting so the rebase doesn't run with a partial {verb}", - missing.join(", ") - ))); - } - Ok(out) -} - -fn rewrite_exec_after(todo: &str, target: &str, command: &str) -> Result<String, CliError> { - let mut matched = false; - let mut out = String::with_capacity(todo.len() + command.len() + 8); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - out.push_str(line); - if matched { - continue; - } - // Match against the *current* line — if it's a pick for the - // target, emit the `exec` line right after. - if let Some(rest) = trimmed.strip_prefix("pick ") { - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - if sha_matches(sha, target) { - let terminator = &line[trimmed.len()..]; - out.push_str("exec "); - out.push_str(command); - out.push_str(if terminator.is_empty() { - "\n" - } else { - terminator - }); - matched = true; - } - } - } - if !matched { - return Err(CliError::InvalidState(format!( - "rebase-todo has no `pick` line for {target}; aborting so the exec doesn't run unanchored" - ))); - } - Ok(out) -} - -/// Reorder the `pick` lines to match `ordered_shas`. Non-pick -/// lines (comments, blank lines, `exec` annotations) are -/// preserved verbatim at the *end* of the rewritten todo, after -/// the new pick order. Mirrors the Python `run_action_rebase` -/// fallback that bucketed "other" lines after the reordered -/// picks. -fn rewrite_reorder(todo: &str, ordered_shas: &[String]) -> Result<String, CliError> { - // First pass: split into pick lines (keyed by SHA) and "other". - let mut pick_lines: Vec<(String, String)> = Vec::new(); - let mut other_lines: String = String::new(); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(rest) = trimmed.strip_prefix("pick ") { - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - pick_lines.push((sha.to_string(), line.to_string())); - } else { - other_lines.push_str(line); - } - } - if ordered_shas.len() != pick_lines.len() { - return Err(CliError::InvalidState(format!( - "rebase-todo reorder: have {} pick lines but caller asked for {} ordered SHAs", - pick_lines.len(), - ordered_shas.len() - ))); - } - // Second pass: rebuild in the requested order, consuming each - // pick at most once so duplicates surface. - let mut consumed = vec![false; pick_lines.len()]; - let mut out = String::with_capacity(todo.len()); - for sha in ordered_shas { - let idx = pick_lines - .iter() - .enumerate() - .position(|(i, (todo_sha, _))| !consumed[i] && sha_matches(todo_sha, sha)); - let Some(idx) = idx else { - return Err(CliError::InvalidState(format!( - "rebase-todo reorder: no remaining pick line matches {sha}" - ))); - }; - consumed[idx] = true; - out.push_str(&pick_lines[idx].1); - } - out.push_str(&other_lines); - Ok(out) -} - -/// Combined reorder + per-SHA verb swap + optional `exec` line. -/// Mirrors Python's `run_action_rebase` with the same call shape -/// the `stack squash` orchestrator uses. -fn rewrite_squash( - todo: &str, - ordered_shas: &[String], - fixup_shas: &[String], - exec_after_sha: Option<&str>, - exec_command: Option<&str>, -) -> Result<String, CliError> { - // Split todo into picks (keyed by SHA) and other lines (kept - // verbatim at the end, same bucketing as Action::Reorder). - let mut pick_lines: Vec<(String, String)> = Vec::new(); - let mut other_lines = String::new(); - for line in todo.split_inclusive('\n') { - let trimmed = line.trim_end_matches(['\n', '\r']); - if let Some(rest) = trimmed.strip_prefix("pick ") { - let (sha, _) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - pick_lines.push((sha.to_string(), line.to_string())); - } else { - other_lines.push_str(line); - } - } - if ordered_shas.len() != pick_lines.len() { - return Err(CliError::InvalidState(format!( - "rebase-todo squash: have {} pick lines but caller asked for {} ordered SHAs", - pick_lines.len(), - ordered_shas.len() - ))); - } - - let exec_set = exec_after_sha.zip(exec_command); - let mut consumed = vec![false; pick_lines.len()]; - let mut out = String::with_capacity(todo.len()); - for sha in ordered_shas { - let idx = pick_lines - .iter() - .enumerate() - .position(|(i, (todo_sha, _))| !consumed[i] && sha_matches(todo_sha, sha)); - let Some(idx) = idx else { - return Err(CliError::InvalidState(format!( - "rebase-todo squash: no remaining pick line matches {sha}" - ))); - }; - consumed[idx] = true; - let original_line = &pick_lines[idx].1; - let trimmed = original_line.trim_end_matches(['\n', '\r']); - let rest = trimmed.strip_prefix("pick ").unwrap_or(trimmed); - let terminator = &original_line[trimmed.len()..]; - let term_to_emit = if terminator.is_empty() { - "\n" - } else { - terminator - }; - - let verb = if fixup_shas.iter().any(|f| sha_matches(sha, f)) { - "fixup" - } else { - "pick" - }; - out.push_str(verb); - out.push(' '); - out.push_str(rest); - out.push_str(term_to_emit); - - if let Some((after_sha, command)) = exec_set - && sha_matches(sha, after_sha) - { - out.push_str("exec "); - out.push_str(command); - out.push_str(term_to_emit); - } - } - out.push_str(&other_lines); - Ok(out) -} - -/// True when *either* `todo_sha` or `target` is a prefix of the -/// other. Mirrors Python's -/// `target.startswith(parts[1]) or parts[1].startswith(target)` -/// so users can paste short or long SHAs from either direction. -fn sha_matches(todo_sha: &str, target: &str) -> bool { - todo_sha.starts_with(target) || target.starts_with(todo_sha) -} - -#[cfg(test)] -mod tests { - use super::*; - - const TODO: &str = "\ -pick 1a2b3c4d feat: add foo -pick deadbeef chore: bump deps -pick cafe1234 fix: typo - -# Rebase abc..def onto abc (3 commands) -"; - - #[test] - fn edit_marks_matching_pick() { - let out = rewrite( - TODO, - &Action::Edit { - sha: "deadbeef".to_string(), - }, - ) - .unwrap(); - assert!(out.contains("edit deadbeef chore: bump deps\n")); - // Other picks left alone. - assert!(out.contains("pick 1a2b3c4d feat: add foo\n")); - assert!(out.contains("pick cafe1234 fix: typo\n")); - // Comment block preserved verbatim. - assert!(out.contains("# Rebase abc..def onto abc (3 commands)\n")); - } - - #[test] - fn edit_matches_by_short_prefix() { - // Long-target/short-todo case — covered by Python's - // `target.startswith(parts[1])`. - let todo = "pick abc12 feat\n"; - let out = rewrite( - todo, - &Action::Edit { - sha: "abc1234567".to_string(), - }, - ) - .unwrap(); - assert_eq!(out, "edit abc12 feat\n"); - } - - #[test] - fn edit_matches_by_long_prefix() { - // Short-target/long-todo case — covered by - // `parts[1].startswith(target)`. - let todo = "pick abc1234567 feat\n"; - let out = rewrite( - todo, - &Action::Edit { - sha: "abc12".to_string(), - }, - ) - .unwrap(); - assert_eq!(out, "edit abc1234567 feat\n"); - } - - #[test] - fn edit_with_no_match_errors() { - let err = rewrite( - TODO, - &Action::Edit { - sha: "ffffffff".to_string(), - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("ffffffff"), "got: {msg}"); - assert!(msg.contains("rebase-todo")); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn edit_preserves_crlf_line_endings() { - // Windows checkouts can hand us CRLF; the rewritten todo - // must keep the same shape or git complains. - let todo = "pick deadbeef chore: bump\r\n"; - let out = rewrite( - todo, - &Action::Edit { - sha: "deadbeef".to_string(), - }, - ) - .unwrap(); - assert_eq!(out, "edit deadbeef chore: bump\r\n"); - } - - #[test] - fn edit_keeps_subjects_with_spaces() { - let todo = "pick 1a2b3c4d feat: support remote/origin/HEAD lookups\n"; - let out = rewrite( - todo, - &Action::Edit { - sha: "1a2b3c4d".to_string(), - }, - ) - .unwrap(); - assert_eq!( - out, - "edit 1a2b3c4d feat: support remote/origin/HEAD lookups\n" - ); - } - - #[test] - fn non_pick_lines_are_left_alone() { - // `git rebase -i --reschedule-failed-exec` etc. may seed - // a todo with `exec`, `fixup`, `reword` lines. We only - // touch `pick` lines. - let todo = "\ -pick 1a2b3c4d feat -fixup deadbeef hotfix -exec cargo test -"; - let out = rewrite( - todo, - &Action::Edit { - sha: "1a2b3c4d".to_string(), - }, - ) - .unwrap(); - assert_eq!( - out, - "edit 1a2b3c4d feat\nfixup deadbeef hotfix\nexec cargo test\n" - ); - } - - #[test] - fn drop_removes_targeted_pick_lines() { - let out = rewrite( - TODO, - &Action::Drop { - shas: vec!["deadbeef".to_string()], - }, - ) - .unwrap(); - assert!(!out.contains("deadbeef")); - assert!(out.contains("pick 1a2b3c4d feat: add foo\n")); - assert!(out.contains("pick cafe1234 fix: typo\n")); - assert!(out.contains("# Rebase abc..def onto abc (3 commands)\n")); - } - - #[test] - fn drop_handles_multiple_shas() { - let out = rewrite( - TODO, - &Action::Drop { - shas: vec!["1a2b3c4d".to_string(), "cafe1234".to_string()], - }, - ) - .unwrap(); - assert!(!out.contains("1a2b3c4d")); - assert!(!out.contains("cafe1234")); - assert!(out.contains("pick deadbeef chore: bump deps\n")); - } - - #[test] - fn drop_with_no_match_errors() { - let err = rewrite( - TODO, - &Action::Drop { - shas: vec!["ffffffff".to_string()], - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("ffffffff"), "got: {msg}"); - assert!(msg.contains("partial drop")); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn drop_with_partial_match_errors_and_lists_missing() { - // Half-and-half: one valid SHA, one missing. Python - // skipped the valid drop and aborted — we do too, and we - // name the missing ones to make the failure actionable. - let err = rewrite( - TODO, - &Action::Drop { - shas: vec!["deadbeef".to_string(), "ffffffff".to_string()], - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("ffffffff"), "got: {msg}"); - assert!(!msg.contains("deadbeef"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn drop_with_empty_target_list_errors() { - let err = rewrite(TODO, &Action::Drop { shas: vec![] }).unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("no commits to drop"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn fixup_rewrites_targeted_pick_to_fixup() { - let out = rewrite( - TODO, - &Action::Fixup { - shas: vec!["deadbeef".to_string()], - }, - ) - .unwrap(); - assert!(out.contains("fixup deadbeef chore: bump deps\n")); - assert!(out.contains("pick 1a2b3c4d feat: add foo\n")); - assert!(out.contains("pick cafe1234 fix: typo\n")); - } - - #[test] - fn fixup_handles_multiple_shas() { - let out = rewrite( - TODO, - &Action::Fixup { - shas: vec!["1a2b3c4d".to_string(), "cafe1234".to_string()], - }, - ) - .unwrap(); - assert!(out.contains("fixup 1a2b3c4d feat: add foo\n")); - assert!(out.contains("fixup cafe1234 fix: typo\n")); - assert!(out.contains("pick deadbeef chore: bump deps\n")); - } - - #[test] - fn fixup_with_no_match_errors() { - let err = rewrite( - TODO, - &Action::Fixup { - shas: vec!["ffffffff".to_string()], - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("ffffffff"), "got: {msg}"); - assert!(msg.contains("partial fixup")); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn reword_marks_matching_pick() { - let out = rewrite( - TODO, - &Action::Reword { - sha: "deadbeef".to_string(), - }, - ) - .unwrap(); - assert!(out.contains("reword deadbeef chore: bump deps\n")); - assert!(out.contains("pick 1a2b3c4d feat: add foo\n")); - } - - #[test] - fn reword_with_no_match_errors() { - let err = rewrite( - TODO, - &Action::Reword { - sha: "ffffffff".to_string(), - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("partial reword"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn exec_after_injects_line_below_target() { - let out = rewrite( - TODO, - &Action::ExecAfter { - sha: "deadbeef".to_string(), - command: "git commit --amend -F /tmp/msg.txt".to_string(), - }, - ) - .unwrap(); - assert_eq!( - out, - "pick 1a2b3c4d feat: add foo\n\ - pick deadbeef chore: bump deps\n\ - exec git commit --amend -F /tmp/msg.txt\n\ - pick cafe1234 fix: typo\n\ - \n\ - # Rebase abc..def onto abc (3 commands)\n" - ); - } - - #[test] - fn reorder_rewrites_picks_in_given_order() { - let out = rewrite( - TODO, - &Action::Reorder { - ordered_shas: vec![ - "cafe1234".to_string(), - "1a2b3c4d".to_string(), - "deadbeef".to_string(), - ], - }, - ) - .unwrap(); - assert_eq!( - out, - "pick cafe1234 fix: typo\n\ - pick 1a2b3c4d feat: add foo\n\ - pick deadbeef chore: bump deps\n\ - \n\ - # Rebase abc..def onto abc (3 commands)\n" - ); - } - - #[test] - fn reorder_count_mismatch_errors() { - let err = rewrite( - TODO, - &Action::Reorder { - ordered_shas: vec!["1a2b3c4d".to_string()], - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!( - msg.contains("3 pick lines") && msg.contains("1 ordered"), - "got: {msg}" - ); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn squash_folds_srcs_into_target_and_keeps_target_verb() { - // ordered: target then src; src is fixup so it folds in. - let out = rewrite( - TODO, - &Action::Squash { - ordered_shas: vec![ - "1a2b3c4d".to_string(), // A - "cafe1234".to_string(), // C (will be fixed up into A) - "deadbeef".to_string(), // B (stays a pick) - ], - fixup_shas: vec!["cafe1234".to_string()], - exec_after_sha: None, - exec_command: None, - }, - ) - .unwrap(); - assert_eq!( - out, - "pick 1a2b3c4d feat: add foo\n\ - fixup cafe1234 fix: typo\n\ - pick deadbeef chore: bump deps\n\ - \n\ - # Rebase abc..def onto abc (3 commands)\n" - ); - } - - #[test] - fn squash_with_exec_after_injects_command() { - let out = rewrite( - TODO, - &Action::Squash { - ordered_shas: vec![ - "1a2b3c4d".to_string(), - "cafe1234".to_string(), - "deadbeef".to_string(), - ], - fixup_shas: vec!["cafe1234".to_string()], - exec_after_sha: Some("cafe1234".to_string()), - exec_command: Some("git commit --amend -F /tmp/msg.txt".to_string()), - }, - ) - .unwrap(); - assert_eq!( - out, - "pick 1a2b3c4d feat: add foo\n\ - fixup cafe1234 fix: typo\n\ - exec git commit --amend -F /tmp/msg.txt\n\ - pick deadbeef chore: bump deps\n\ - \n\ - # Rebase abc..def onto abc (3 commands)\n" - ); - } - - #[test] - fn squash_count_mismatch_errors() { - let err = rewrite( - TODO, - &Action::Squash { - ordered_shas: vec!["1a2b3c4d".to_string()], - fixup_shas: vec![], - exec_after_sha: None, - exec_command: None, - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("3 pick lines"), "got: {msg}"); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn reorder_unknown_sha_errors() { - let err = rewrite( - TODO, - &Action::Reorder { - ordered_shas: vec![ - "ffffffff".to_string(), - "1a2b3c4d".to_string(), - "deadbeef".to_string(), - ], - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("ffffffff"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn exec_after_with_no_match_errors() { - let err = rewrite( - TODO, - &Action::ExecAfter { - sha: "ffffffff".to_string(), - command: "true".to_string(), - }, - ) - .unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("ffffffff"), "got: {msg}"); - assert!(msg.contains("unanchored")); - } - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/remote_changes.rs b/crates/mergify-stack/src/remote_changes.rs deleted file mode 100644 index bfe935e8..00000000 --- a/crates/mergify-stack/src/remote_changes.rs +++ /dev/null @@ -1,774 +0,0 @@ -//! Discover the open + recently merged PRs belonging to a stack. -//! -//! Every `mergify stack <cmd>` that needs to know which PRs are -//! already on GitHub for the current branch runs this. It only -//! calls repository-scoped endpoints (`/repos/{owner}/{repo}/...`): -//! some environments proxy the GitHub API and refuse everything -//! else (Claude Code on the web answers `/search/issues` with a -//! 403, Mergifyio/mergify-cli#1861), and the Search API has its -//! own small rate limit on top. -//! -//! 1. `GET /repos/owner/repo/git/matching-refs/heads/<prefix>/` — -//! every branch that exists under the stack prefix, in one -//! unpaginated call. Kept: branches one segment below the prefix -//! whose last segment carries a Change-Id, i.e. the shape -//! `stack push` creates. -//! 2. Add the branch each local commit would be pushed to -//! (`<prefix>/<slug>`) when no live branch carries its -//! Change-Id. That is how a merged PR is still found after its -//! head branch was deleted: `SkipMerged` needs the PR's -//! `head.sha` to equal the local commit, and a commit that -//! unchanged has the same title, so the same slug. A commit -//! reworded since has a different SHA and is re-created -//! whether its merged PR is found or not. -//! 3. For each of those branches, -//! `GET /repos/owner/repo/pulls?head=owner:<branch>&state=all`. -//! That filter reads the PR's recorded head, so it answers for -//! closed and merged PRs whose branch is gone. -//! 4. Sort everything by `updated_at`, newest first, and group by -//! [`change_id::extract_from_branch_segment`] applied to the -//! last segment of `head.ref`. Closed-but-not-merged PRs are -//! dropped. When two PRs share the same Change-Id, open beats -//! closed — and two open PRs on the same Change-Id is a hard -//! error (the user has a duplicate that the rest of the -//! orchestration can't reconcile). -//! -//! The payloads are the pull-request *list* shape: everything the -//! single-PR endpoint returns except the computed fields -//! (`mergeable`, `mergeable_state`, `merged`, diff stats). A -//! consumer that needs one of those fetches the PR itself. -//! -//! What the Search API found and this does not: a merged PR whose -//! branch was deleted and whose Change-Id is no longer in the local -//! stack. Consumers only report *open* orphans, so nothing reads it. -//! -//! The output is order-preserving — the `updated_at` ordering -//! carries through to downstream consumers (the orphan list is -//! presented in that order). - -use mergify_core::http::Client; -use mergify_core::{ApiFlavor, CliError}; -use serde::{Deserialize, Serialize}; - -use crate::change_id; -use crate::local_commits::LocalCommit; - -/// One `{change_id, pull}` entry, most recently updated first. -/// Emitted as a flat array (not a JSON object) so consumers can -/// rebuild an order-preserving dict without relying on serde's -/// optional `preserve_order` feature. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RemoteChange { - pub change_id: String, - /// Raw PR payload from `/repos/owner/repo/pulls?head=...` (the - /// list shape — no `mergeable`). Passed through as a typeless - /// JSON value — the downstream orchestrator consumes many - /// fields (`head.ref`, `head.sha`, `state`, `draft`, - /// `merged_at`, `merge_commit_sha`, `html_url`, `stack`, …); - /// typing them all here would be a translation tax with no - /// real correctness win. - pub pull: serde_json::Value, -} - -/// Run the list-branches → look-up → group pipeline. -/// -/// `user` / `repo` name the repository the stack lives in. -/// `stack_prefix` is the branch prefix the stack's PR branches sit -/// under. `author` keeps only PRs one user opened — `Some(me)` for -/// the commands that only ever manage the local user's own stack, -/// `None` for `stack checkout`, which has to reach anyone's stack. -/// `local_commits` supplies the branch names a merged PR may still -/// be recorded under after its branch was deleted; pass `&[]` when -/// there is no local stack (then only live branches are searched). -pub async fn get_remote_changes( - client: &Client, - user: &str, - repo: &str, - stack_prefix: &str, - author: Option<&str>, - local_commits: &[LocalCommit], -) -> Result<Vec<RemoteChange>, CliError> { - get_remote_changes_reporting( - client, - user, - repo, - stack_prefix, - author, - local_commits, - |_, _| {}, - ) - .await -} - -/// Same as [`get_remote_changes`], but calls `on_lookup(n, total)` -/// just before the `n`-th (1-based) of `total` per-branch lookups — -/// so a caller can surface progress through the otherwise-silent -/// sequential loop. -pub async fn get_remote_changes_reporting( - client: &Client, - user: &str, - repo: &str, - stack_prefix: &str, - author: Option<&str>, - local_commits: &[LocalCommit], - mut on_lookup: impl FnMut(usize, usize), -) -> Result<Vec<RemoteChange>, CliError> { - let live = live_stack_branches(client, user, repo, stack_prefix).await?; - let branches = branches_to_look_up(live, stack_prefix, local_commits); - - // Sequential on purpose: GitHub's secondary rate limit kicks in - // on bursts of concurrent requests, and a stack is a handful of - // branches (~100ms each). - let pulls_path = format!("/repos/{user}/{repo}/pulls"); - let mut pulls: Vec<serde_json::Value> = Vec::new(); - for (idx, branch) in branches.iter().enumerate() { - on_lookup(idx + 1, branches.len()); - let head = format!("{user}:{branch}"); - let found: Vec<serde_json::Value> = client - .get_with_query( - &pulls_path, - &[("head", &head), ("state", "all"), ("per_page", "100")], - ) - .await?; - pulls.extend( - found - .into_iter() - .filter(|pull| author.is_none_or(|a| opened_by(pull, a))), - ); - } - - // ISO-8601 UTC timestamps sort chronologically as strings. A - // stable sort keeps lookup order between equal timestamps. - pulls.sort_by(|a, b| updated_at(b).cmp(updated_at(a))); - group_by_change_id(pulls) -} - -/// Branches that exist right now one segment below `stack_prefix` -/// and end in a Change-Id — the branches `stack push` creates. -/// A nested stack (`<prefix>/<sub>/...`) or a hand-made branch -/// under the prefix is not this stack's. -async fn live_stack_branches( - client: &Client, - user: &str, - repo: &str, - stack_prefix: &str, -) -> Result<Vec<String>, CliError> { - // The trailing slash matters: `matching-refs` is a plain string - // prefix match, so without it `stack/foo` would also list - // `stack/foobar/...`. - let path = format!("/repos/{user}/{repo}/git/matching-refs/heads/{stack_prefix}/"); - let refs: Vec<GitRef> = client.get(&path).await?; - let ref_prefix = format!("refs/heads/{stack_prefix}/"); - Ok(refs - .into_iter() - .filter_map(|r| { - let leaf = r.ref_name.strip_prefix(&ref_prefix)?; - let managed = - !leaf.contains('/') && change_id::extract_from_branch_segment(leaf).is_some(); - managed.then(|| format!("{stack_prefix}/{leaf}")) - }) - .collect()) -} - -/// `live`, plus the branch each local commit would be pushed to -/// when no live branch already carries its Change-Id. -fn branches_to_look_up( - live: Vec<String>, - stack_prefix: &str, - local_commits: &[LocalCommit], -) -> Vec<String> { - let live_change_ids: Vec<String> = live - .iter() - .filter_map(|b| change_id::extract_from_branch_segment(last_segment(b))) - .map(str::to_owned) - .collect(); - let mut branches = live; - for local in local_commits { - if live_change_ids - .iter() - .any(|remote| same_change(remote, &local.change_id)) - { - continue; - } - let expected = format!("{stack_prefix}/{slug}", slug = local.slug); - if !branches.contains(&expected) { - branches.push(expected); - } - } - branches -} - -/// Whether a branch's Change-Id (the short 8-hex form or the full -/// `I` + 40 hex) and a local commit's full Change-Id name the same -/// change. The same prefix comparison the classifiers pair commits -/// with PRs by. -fn same_change(remote: &str, local: &str) -> bool { - let remote_hex = remote.strip_prefix('I').unwrap_or(remote); - let local_hex = local.strip_prefix('I').unwrap_or(local); - remote_hex.starts_with(local_hex) || local_hex.starts_with(remote_hex) -} - -fn last_segment(branch: &str) -> &str { - branch.rsplit('/').next().unwrap_or(branch) -} - -/// GitHub logins are case-insensitive, as the Search API's -/// `author:` qualifier this replaces was. -fn opened_by(pull: &serde_json::Value, author: &str) -> bool { - pull.pointer("/user/login") - .and_then(serde_json::Value::as_str) - .is_some_and(|login| login.eq_ignore_ascii_case(author)) -} - -fn updated_at(pull: &serde_json::Value) -> &str { - pull.get("updated_at") - .and_then(serde_json::Value::as_str) - .unwrap_or("") -} - -/// Pure, network-free regrouping of the per-PR payloads. Exposed -/// so the lookup + group split can be unit-tested in isolation -/// without spinning up a mock server for every parser edge case. -fn group_by_change_id(pulls: Vec<serde_json::Value>) -> Result<Vec<RemoteChange>, CliError> { - let mut out: Vec<RemoteChange> = Vec::new(); - for pull in pulls { - // Closed-but-not-merged PRs are dropped early — they - // have no role in the stack discovery flow and keeping - // them would only inflate the orphan list. - let state = pull.get("state").and_then(serde_json::Value::as_str); - let merged_at = pull.get("merged_at"); - if state == Some("closed") && merged_at.is_some_and(serde_json::Value::is_null) { - continue; - } - - let head_ref = pull - .pointer("/head/ref") - .and_then(serde_json::Value::as_str) - .ok_or_else(|| { - CliError::GitHubApi("PR payload missing required `head.ref` field".to_string()) - })?; - let Some(change_id) = change_id::extract_from_branch_segment(last_segment(head_ref)) else { - continue; - }; - let change_id = change_id.to_string(); - - if let Some(existing) = out.iter_mut().find(|c| c.change_id == change_id) { - let other_state = existing - .pull - .get("state") - .and_then(serde_json::Value::as_str); - match (other_state, state) { - (Some("closed"), Some("open")) => { - existing.pull = pull; - } - (Some("open"), Some("open")) => { - // Two open PRs on the same Change-Id is a - // user-state bug the rest of the - // orchestration can't reconcile (push would - // race, lease check would clobber). Surface - // loudly so the user closes one manually. - return Err(CliError::InvalidState(format!( - "More than 1 pull found with this head: {head_ref}" - ))); - } - // Open-existing + closed-new, or both closed: - // keep the existing entry. The input is sorted by - // `updated_at`, newest first, so the first-seen - // one is the most recent. - _ => {} - } - } else { - out.push(RemoteChange { change_id, pull }); - } - } - Ok(out) -} - -#[derive(Debug, Deserialize)] -struct GitRef { - #[serde(rename = "ref")] - ref_name: String, -} - -/// Build an [`ApiFlavor::GitHub`] client pre-configured for the -/// given server + token. Convenience for the binary wrapper so -/// the `_internal stack-remote-changes` arm doesn't need to -/// re-implement the construction every call. -pub fn default_client(github_server: url::Url, token: &str) -> Result<Client, CliError> { - Client::new(github_server, token.to_string(), ApiFlavor::GitHub) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{Value, json}; - use url::Url; - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn pr(number: u64, state: &str, head_ref: &str, merged_at: Option<&str>) -> serde_json::Value { - serde_json::json!({ - "number": number, - "state": state, - "draft": false, - "merged_at": merged_at, - "head": { "ref": head_ref, "sha": format!("sha{number}") }, - }) - } - - #[test] - fn group_skips_closed_unmerged_pr() { - // Closed-but-not-merged PRs are abandoned drafts that - // never made it in; keeping them would force the orphan - // list to surface stale entries. - let pulls = vec![pr(1, "closed", "prefix/feat-a--aaaaaaaa", None)]; - let out = group_by_change_id(pulls).unwrap(); - assert!(out.is_empty(), "got: {out:?}"); - } - - #[test] - fn group_keeps_closed_merged_pr() { - // Closed + merged_at present → the PR landed; downstream - // code uses it to mark the change as `skip-merged`. - let pulls = vec![pr( - 1, - "closed", - "prefix/feat-a--aaaaaaaa", - Some("2026-01-01T00:00:00Z"), - )]; - let out = group_by_change_id(pulls).unwrap(); - assert_eq!(out.len(), 1); - assert_eq!(out[0].change_id, "aaaaaaaa"); - } - - #[test] - fn group_extracts_change_id_from_new_style_short_suffix() { - // New-style branches end in `--<8 hex>`; the change-id - // helper returns the short hex tail rather than the full - // I-prefixed form. - let pulls = vec![pr(1, "open", "prefix/improve-thing--deadbeef", None)]; - let out = group_by_change_id(pulls).unwrap(); - assert_eq!(out[0].change_id, "deadbeef"); - } - - #[test] - fn group_extracts_change_id_from_old_style_full_segment() { - // Old-style: the entire last segment IS the Change-Id - // (I + 40 hex). The helper returns it verbatim. - let full = "I0123456789abcdef0123456789abcdef01234567"; - let pulls = vec![pr(1, "open", &format!("prefix/{full}"), None)]; - let out = group_by_change_id(pulls).unwrap(); - assert_eq!(out[0].change_id, full); - } - - #[test] - fn group_drops_pr_whose_branch_has_no_recognisable_change_id() { - // Some user manually pushed a branch under the prefix - // without going through `mergify stack`, and opened a PR - // on it. Dropping it keeps the result hermetic to managed - // PRs. - let pulls = vec![pr(1, "open", "prefix/random-branch", None)]; - let out = group_by_change_id(pulls).unwrap(); - assert!(out.is_empty(), "got: {out:?}"); - } - - #[test] - fn group_picks_open_over_closed_when_change_ids_collide() { - // Common amend-after-merge pattern: an older closed PR - // exists for a Change-Id that's been rebased and reopened - // under a fresh branch. The open one is the live source - // of truth. - let pulls = vec![ - pr(1, "closed", "prefix/feat-a--deadbeef", None), - pr(2, "open", "prefix/feat-a--deadbeef", None), - ]; - let out = group_by_change_id(pulls).unwrap(); - assert_eq!(out.len(), 1); - assert_eq!(out[0].pull["number"], 2); - } - - #[test] - fn group_preserves_first_seen_order_across_distinct_change_ids() { - // The lookup sorts by `updated_at` before grouping, and - // the orphan list observes that order. Confirm grouping - // doesn't silently re-sort it. - let pulls = vec![ - pr(1, "open", "prefix/feat-a--aaaaaaaa", None), - pr(2, "open", "prefix/feat-b--bbbbbbbb", None), - pr(3, "open", "prefix/feat-c--cccccccc", None), - ]; - let out = group_by_change_id(pulls).unwrap(); - assert_eq!( - out.iter().map(|c| c.change_id.as_str()).collect::<Vec<_>>(), - vec!["aaaaaaaa", "bbbbbbbb", "cccccccc"], - ); - } - - #[test] - fn group_errors_on_two_open_prs_with_same_change_id() { - // This is a user-state bug the rest of the orchestration - // can't reconcile — surface it loudly so the user can - // close one of the duplicates manually. - let pulls = vec![ - pr(1, "open", "prefix/feat-a--deadbeef", None), - pr(2, "open", "prefix/feat-a--deadbeef", None), - ]; - let err = group_by_change_id(pulls).unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!(msg.contains("More than 1 pull found"), "got: {msg}"); - assert!(msg.contains("prefix/feat-a--deadbeef"), "got: {msg}"); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn group_errors_when_pr_payload_lacks_head_ref() { - // Malformed PR response — GitHub guarantees `head.ref` - // for pull requests, so an absent field means we got - // back something we didn't expect (cache poison, custom - // proxy, etc.). Better to error than to silently drop. - let pulls = vec![serde_json::json!({ - "number": 1, - "state": "open", - "merged_at": null, - "head": {}, - })]; - let err = group_by_change_id(pulls).unwrap_err(); - match err { - CliError::GitHubApi(msg) => { - assert!(msg.contains("head.ref"), "got: {msg}"); - } - other => panic!("expected GitHubApi, got: {other:?}"), - } - } - - const ID_A: &str = "Iaaaaaaaa0000000000000000000000000000000a"; - const ID_B: &str = "Ibbbbbbbb0000000000000000000000000000000b"; - - fn local(change_id: &str, slug: &str) -> LocalCommit { - LocalCommit { - commit_sha: format!("sha-{slug}"), - title: slug.to_string(), - message: String::new(), - change_id: change_id.to_string(), - slug: slug.to_string(), - note: String::new(), - } - } - - /// A list-endpoint PR payload, with the fields the lookup - /// filters and sorts on. - fn listed(number: u64, state: &str, head_ref: &str, login: &str, updated: &str) -> Value { - let mut pull = pr(number, state, head_ref, None); - pull["user"] = json!({ "login": login }); - pull["updated_at"] = json!(updated); - pull - } - - fn refs(names: &[&str]) -> Value { - Value::Array( - names - .iter() - .map(|n| json!({ "ref": format!("refs/heads/{n}"), "object": { "sha": "x" } })) - .collect(), - ) - } - - async fn mount_refs(server: &MockServer, prefix: &str, names: &[&str]) { - Mock::given(method("GET")) - .and(path(format!( - "/repos/user/repo/git/matching-refs/heads/{prefix}/" - ))) - .respond_with(ResponseTemplate::new(200).set_body_json(refs(names))) - .expect(1) - .mount(server) - .await; - } - - async fn mount_lookup(server: &MockServer, branch: &str, pulls: Value) { - Mock::given(method("GET")) - .and(path("/repos/user/repo/pulls")) - .and(query_param("head", format!("user:{branch}"))) - .and(query_param("state", "all")) - .and(query_param("per_page", "100")) - .respond_with(ResponseTemplate::new(200).set_body_json(pulls)) - .expect(1) - .mount(server) - .await; - } - - /// The endpoint this module used to call. Mounted with - /// `expect(0)` so any regression back to it fails the test. - async fn forbid_search(server: &MockServer) { - Mock::given(path("/search/issues")) - .respond_with(ResponseTemplate::new(403)) - .expect(0) - .mount(server) - .await; - } - - fn client(server: &MockServer) -> Client { - Client::new( - Url::parse(&server.uri()).unwrap(), - "tok".to_string(), - ApiFlavor::GitHub, - ) - .unwrap() - } - - #[tokio::test] - async fn looks_up_each_live_stack_branch_without_the_search_api() { - let server = MockServer::start().await; - forbid_search(&server).await; - mount_refs( - &server, - "prefix", - &["prefix/feat-a--aaaaaaaa", "prefix/feat-b--bbbbbbbb"], - ) - .await; - mount_lookup( - &server, - "prefix/feat-a--aaaaaaaa", - json!([listed( - 11, - "open", - "prefix/feat-a--aaaaaaaa", - "author", - "2026-01-01T00:00:00Z" - )]), - ) - .await; - mount_lookup( - &server, - "prefix/feat-b--bbbbbbbb", - json!([listed( - 22, - "open", - "prefix/feat-b--bbbbbbbb", - "author", - "2026-01-02T00:00:00Z" - )]), - ) - .await; - - let mut progress = Vec::new(); - let got = get_remote_changes_reporting( - &client(&server), - "user", - "repo", - "prefix", - Some("author"), - &[], - |n, total| progress.push((n, total)), - ) - .await - .unwrap(); - - // Most recently updated first, as the Search API's - // `sort=updated` used to return them. - assert_eq!( - got.iter() - .map(|c| (c.change_id.as_str(), c.pull["number"].as_u64())) - .collect::<Vec<_>>(), - vec![("bbbbbbbb", Some(22)), ("aaaaaaaa", Some(11))], - ); - assert_eq!(progress, vec![(1, 2), (2, 2)]); - } - - #[tokio::test] - async fn finds_a_merged_pull_whose_branch_was_deleted_through_the_local_slug() { - // The bottom PR merged and GitHub deleted its branch, so - // `matching-refs` no longer lists it. `stack sync` still - // has to see that PR as merged to drop the local commit. - let server = MockServer::start().await; - forbid_search(&server).await; - mount_refs(&server, "prefix", &["prefix/feat-b--bbbbbbbb"]).await; - mount_lookup( - &server, - "prefix/feat-b--bbbbbbbb", - json!([listed( - 22, - "open", - "prefix/feat-b--bbbbbbbb", - "author", - "2026-01-02T00:00:00Z" - )]), - ) - .await; - let mut merged = listed( - 11, - "closed", - "prefix/feat-a--aaaaaaaa", - "author", - "2026-01-01T00:00:00Z", - ); - merged["merged_at"] = json!("2026-01-01T00:00:00Z"); - mount_lookup(&server, "prefix/feat-a--aaaaaaaa", json!([merged])).await; - - let locals = [ - local(ID_A, "feat-a--aaaaaaaa"), - local(ID_B, "feat-b--bbbbbbbb"), - ]; - let got = get_remote_changes( - &client(&server), - "user", - "repo", - "prefix", - Some("author"), - &locals, - ) - .await - .unwrap(); - - let merged = got - .iter() - .find(|c| c.change_id == "aaaaaaaa") - .expect("merged pull found"); - assert_eq!(merged.pull["number"], 11); - assert_eq!(merged.pull["merged_at"], "2026-01-01T00:00:00Z"); - assert_eq!(got.len(), 2); - } - - #[tokio::test] - async fn skips_the_slug_lookup_for_a_change_already_on_a_live_branch() { - // The commit was reworded after its PR opened: the PR keeps - // its original branch, the local slug now differs. The live - // branch already answers for that Change-Id, so the - // reworded slug is not looked up. - let server = MockServer::start().await; - forbid_search(&server).await; - mount_refs(&server, "prefix", &["prefix/old-title--aaaaaaaa"]).await; - mount_lookup( - &server, - "prefix/old-title--aaaaaaaa", - json!([listed( - 11, - "open", - "prefix/old-title--aaaaaaaa", - "author", - "2026-01-01T00:00:00Z" - )]), - ) - .await; - Mock::given(method("GET")) - .and(path("/repos/user/repo/pulls")) - .and(query_param("head", "user:prefix/new-title--aaaaaaaa")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(0) - .mount(&server) - .await; - - let got = get_remote_changes( - &client(&server), - "user", - "repo", - "prefix", - Some("author"), - &[local(ID_A, "new-title--aaaaaaaa")], - ) - .await - .unwrap(); - - assert_eq!(got.len(), 1); - assert_eq!(got[0].pull["number"], 11); - } - - #[tokio::test] - async fn ignores_nested_and_unmanaged_branches_under_the_prefix() { - // `prefix/sub/...` is another stack; `prefix/random` was - // never pushed by `mergify stack`. Neither is looked up. - let server = MockServer::start().await; - forbid_search(&server).await; - mount_refs( - &server, - "prefix", - &["prefix/sub/feat-x--cccccccc", "prefix/random-branch"], - ) - .await; - Mock::given(method("GET")) - .and(path("/repos/user/repo/pulls")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(0) - .mount(&server) - .await; - - let got = get_remote_changes(&client(&server), "user", "repo", "prefix", None, &[]) - .await - .unwrap(); - - assert!(got.is_empty(), "got: {got:?}"); - } - - #[tokio::test] - async fn author_filter_is_case_insensitive_and_absent_without_an_author() { - // `stack checkout` passes no author and must see anyone's - // PR; the owner-only commands keep just their own, matching - // the login the way GitHub does (case-insensitively). - for (author, expected) in [ - (Some("AUTHOR"), vec![11]), - (Some("someone-else"), vec![]), - (None, vec![11]), - ] { - let server = MockServer::start().await; - mount_refs(&server, "prefix", &["prefix/feat-a--aaaaaaaa"]).await; - mount_lookup( - &server, - "prefix/feat-a--aaaaaaaa", - json!([listed( - 11, - "open", - "prefix/feat-a--aaaaaaaa", - "author", - "2026-01-01T00:00:00Z" - )]), - ) - .await; - - let got = get_remote_changes(&client(&server), "user", "repo", "prefix", author, &[]) - .await - .unwrap(); - - assert_eq!( - got.iter() - .filter_map(|c| c.pull["number"].as_u64()) - .collect::<Vec<_>>(), - expected, - "author {author:?}", - ); - } - } - - #[tokio::test] - async fn keeps_the_most_recently_updated_of_two_merged_pulls_on_one_change() { - // Both merged, one Change-Id (re-opened and merged twice): - // the newest wins, whatever order the lookups ran in. - let server = MockServer::start().await; - mount_refs(&server, "prefix", &["prefix/feat-a--aaaaaaaa"]).await; - let mut older = listed( - 11, - "closed", - "prefix/feat-a--aaaaaaaa", - "author", - "2026-01-01T00:00:00Z", - ); - older["merged_at"] = json!("2026-01-01T00:00:00Z"); - let mut newer = listed( - 12, - "closed", - "prefix/feat-a--aaaaaaaa", - "author", - "2026-02-01T00:00:00Z", - ); - newer["merged_at"] = json!("2026-02-01T00:00:00Z"); - mount_lookup(&server, "prefix/feat-a--aaaaaaaa", json!([older, newer])).await; - - let got = get_remote_changes(&client(&server), "user", "repo", "prefix", None, &[]) - .await - .unwrap(); - - assert_eq!(got.len(), 1); - assert_eq!(got[0].pull["number"], 12); - } -} diff --git a/crates/mergify-stack/src/replay.rs b/crates/mergify-stack/src/replay.rs deleted file mode 100644 index 3ea37bb9..00000000 --- a/crates/mergify-stack/src/replay.rs +++ /dev/null @@ -1,864 +0,0 @@ -//! Replay an amendment onto its old PR head as a synthetic commit. -//! -//! Background: when `stack push` force-pushes an amended commit, -//! the revision-history "compare" URL `…/compare/<old_sha>...<new_sha>` -//! is useless when the rebase moved the base — reviewers see the -//! whole PR diff instead of just the edit. The fix is to build a -//! synthetic commit whose tree is *only* the user's amendment -//! and whose parent is `old_sha`, then point compare at that. -//! -//! This module ports `mergify_cli/stack/replay.py`: -//! -//! - [`compute_merged_tree`] — `git merge-tree --write-tree` to -//! replay the new PR head onto `parent(old_sha)`, returning the -//! resulting tree SHA. -//! - [`compute_tree_delta`] — `git diff-tree --raw -z --no-renames` -//! between two trees, parsed into entries shaped for the -//! GitHub `POST /repos/.../git/trees` API. -//! - [`upload_replay_commit`] — chains -//! `POST /git/trees` + `POST /git/commits` to materialise the -//! synthetic commit on GitHub. Returns the commit SHA the -//! revision-history compare URL anchors at. -//! - [`replay_for_revision`] — top-level entry point wiring all -//! three above together; returns `None` whenever the -//! rebase-aware compare URL can't be produced so the caller -//! falls back to the plain `old…new` three-dot URL. - -use std::path::Path; - -use crate::git::{run_git_capture, run_git_capture_raw}; - -use mergify_core::{CliError, HttpClient}; -use serde::{Deserialize, Serialize}; - -/// Output of [`compute_merged_tree`]: the merged tree SHA paired -/// with the commit SHA the tree is anchored on (parent of -/// `old_sha`). Callers need both: the tree for the diff against -/// `parent_old_sha^{tree}`, the parent SHA so the synthetic -/// commit's `parents` field can use the right value. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MergedTree { - pub tree_sha: String, - pub parent_old_sha: String, -} - -/// One entry in the body of `POST /repos/.../git/trees` — modulo -/// the `null` SHA used for deletions, the shape is GitHub's Git -/// Data API verbatim. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct TreeEntry { - pub path: String, - pub mode: String, - /// Renamed because `type` is a Rust keyword. The wire field is - /// `"type"` to match GitHub. - #[serde(rename = "type")] - pub type_: String, - /// `None` -> JSON `null`, which GitHub interprets as "delete - /// this path from `base_tree`." - pub sha: Option<String>, -} - -/// Replay `new_sha` onto `parent(old_sha)` and return the merged -/// tree SHA. Returns `None` whenever the rebase-aware compare URL -/// can't be produced — conflict, missing parents, or any git -/// failure — so the caller can fall back to the plain -/// `old…new` three-dot URL. -/// -/// Requires git ≥ 2.38 for `git merge-tree --write-tree`. Older -/// gits fail with non-zero exit, which gets coerced to `None` -/// here (same behaviour as Python). -#[must_use] -pub fn compute_merged_tree( - repo_dir: Option<&Path>, - old_sha: &str, - new_sha: &str, -) -> Option<MergedTree> { - let parent_old_sha = run_git_capture(repo_dir, &["rev-parse", &format!("{old_sha}^")]).ok()?; - let parent_new_sha = run_git_capture(repo_dir, &["rev-parse", &format!("{new_sha}^")]).ok()?; - - let output = run_git_capture( - repo_dir, - &[ - "merge-tree", - "--write-tree", - &format!("--merge-base={parent_new_sha}"), - &parent_old_sha, - new_sha, - ], - ) - .ok()?; - - // On a clean merge, the first line of stdout is the tree SHA. - // A conflict prints conflict markers on later lines and exits - // non-zero — that case is already covered by the `?` above. - let tree_sha = output.lines().next()?.to_string(); - if tree_sha.is_empty() { - return None; - } - - Some(MergedTree { - tree_sha, - parent_old_sha, - }) -} - -/// Convert a `git` tree-entry mode to the GitHub Git Data API's -/// `type` field (`blob` / `tree` / `commit`). Anything we don't -/// recognise falls back to `blob` so a future mode value can't -/// crash the parser. -#[must_use] -pub fn mode_to_type(mode: &str) -> &'static str { - match mode { - "160000" => "commit", - "040000" => "tree", - _ => "blob", - } -} - -/// Parse `git diff-tree -r --raw -z --no-renames base merged` into -/// GitHub `git/trees` entries. -/// -/// `-z` makes each entry two NUL-terminated fields, -/// `":mode_old mode_new sha_old sha_new STATUS\0path\0"`. Without -/// it git applies `core.quotePath` (on by default), which wraps -/// any path holding a non-ASCII byte, a double quote, or a -/// backslash in quotes and octal-escapes it — and that literal -/// `"src/cach\303\251.txt"` is what we would upload, so the replay -/// commit would create a file under the escaped name and leave the -/// real one in place (MRGFY-8288). Setting `core.quotePath=false` -/// would not do: git escapes `"` and `\` whatever that says. `-z` -/// also removes the only reason a path could not contain a tab or -/// a newline, which line-based parsing cannot survive either. -/// Only `R`/`C` entries carry a second path, and `--no-renames` -/// suppresses those, so every record here is exactly two fields. -/// -/// We preserve only the `M` (modified), `A` (added), `T` (type- -/// changed), and `D` (deleted) statuses. `--no-renames` suppresses -/// `R`/`C` already; any future status we don't know is dropped on -/// the floor rather than coerced into one of these — a silent -/// misclassification would upload the wrong thing, where a missing -/// entry only leaves the compare URL incomplete. -/// -/// A path that is not valid UTF-8 is a hard error: the trees API -/// is JSON, so no encoding uploads it correctly, and picking one -/// anyway (`from_utf8_lossy`) recreates the corruption above. -/// [`replay_for_revision`] turns any error here into "no -/// rebase-aware compare URL" and falls back to the plain `old…new` -/// range, same as it does for a conflict. -/// -/// Returns an empty vec when the diff is empty (e.g. the merged -/// tree equals `base_tree_sha`). Caller treats that as "nothing -/// to upload" and skips the replay commit. -pub fn compute_tree_delta( - repo_dir: Option<&Path>, - base_tree_sha: &str, - merged_tree_sha: &str, -) -> Result<Vec<TreeEntry>, CliError> { - let output = run_git_capture_raw( - repo_dir, - &[ - "diff-tree", - "-r", - "--raw", - "-z", - "--no-renames", - base_tree_sha, - merged_tree_sha, - ], - )?; - - let mut entries = Vec::new(); - let mut fields = output.split(|b| *b == 0); - while let Some(meta) = fields.next() { - // Every record opens with ':'; the only other field the - // split yields is the empty tail after the final NUL. - let Some(meta) = meta.strip_prefix(b":") else { - continue; - }; - let Some(path) = fields.next() else { break }; - // Modes, SHAs and the status letter are ASCII by - // construction — a header that isn't is not a record we - // know how to read. - let Ok(meta) = std::str::from_utf8(meta) else { - continue; - }; - let parts: Vec<&str> = meta.split_whitespace().collect(); - let [mode_old, mode_new, _sha_old, sha_new, status] = parts[..] else { - continue; - }; - let path = String::from_utf8(path.to_vec()).map_err(|e| { - CliError::wrap( - format!( - "`git diff-tree` returned a path that is not valid UTF-8, \ - so the replay commit cannot be uploaded: {}", - String::from_utf8_lossy(path).escape_debug() - ), - e, - ) - })?; - - match status { - "D" => entries.push(TreeEntry { - path, - mode: mode_old.to_string(), - type_: mode_to_type(mode_old).to_string(), - sha: None, - }), - "M" | "A" | "T" => entries.push(TreeEntry { - path, - mode: mode_new.to_string(), - type_: mode_to_type(mode_new).to_string(), - sha: Some(sha_new.to_string()), - }), - _ => { - // Unknown status — drop. Better than coercing to - // something arbitrary and corrupting the upload. - } - } - } - Ok(entries) -} - -/// Materialise a synthetic commit on GitHub: `POST /git/trees` -/// with the delta to plant a tree, then `POST /git/commits` with -/// `parents=[old_sha]` so `compare/<old_sha>...<commit>` shows -/// only the amendment. -/// -/// Returns the commit SHA on success or `None` on any API error -/// (matches Python's `except httpx.HTTPError, ValueError`). The -/// unreferenced object will be GC'd by GitHub eventually; the -/// compare URL works in the meantime — and the -/// revision-history table includes a `(raw)` fallback URL for -/// when it goes away. -pub async fn upload_replay_commit( - client: &HttpClient, - user: &str, - repo: &str, - base_tree_sha: &str, - old_sha: &str, - new_sha: &str, - entries: &[TreeEntry], -) -> Option<String> { - let tree_path = format!("/repos/{user}/{repo}/git/trees"); - let tree_payload = TreePayload { - base_tree: base_tree_sha, - tree: entries, - }; - let tree_resp: ShaResponse = client.post(&tree_path, &tree_payload).await.ok()?; - - let commit_path = format!("/repos/{user}/{repo}/git/commits"); - let commit_payload = CommitPayload { - message: format!( - "mergify-cli: replay {} on {}", - short(new_sha), - short(old_sha), - ), - tree: &tree_resp.sha, - parents: vec![old_sha], - }; - let commit_resp: ShaResponse = client.post(&commit_path, &commit_payload).await.ok()?; - Some(commit_resp.sha) -} - -/// Top-level entry point: orchestrates [`compute_merged_tree`] → -/// `rev-parse parent_old^{{tree}}` → [`compute_tree_delta`] → -/// [`upload_replay_commit`], returning the server commit SHA the -/// revision-history compare URL anchors at. -/// -/// Returns `None` whenever the rebase-aware compare URL can't be -/// produced (conflict, missing parents, no diff, git error, API -/// error). Callers must fall back to the plain `old…new` -/// three-dot URL anchored at `old_sha`. -pub async fn replay_for_revision( - client: &HttpClient, - repo_dir: Option<&Path>, - user: &str, - repo: &str, - old_sha: &str, - new_sha: &str, -) -> Option<String> { - let merged = compute_merged_tree(repo_dir, old_sha, new_sha)?; - - // `^{tree}` dereferences a commit SHA to its root tree SHA - // (git plumbing syntax). Needed so the diff in - // `compute_tree_delta` starts from the right tree. - let parent_old_tree_sha = run_git_capture( - repo_dir, - &["rev-parse", &format!("{}^{{tree}}", merged.parent_old_sha)], - ) - .ok()?; - - let entries = compute_tree_delta(repo_dir, &parent_old_tree_sha, &merged.tree_sha).ok()?; - if entries.is_empty() { - // The rebase fully absorbs the user's edit, or the merge - // produced an identical tree — nothing to compare, no - // synth commit to upload. - return None; - } - - upload_replay_commit( - client, - user, - repo, - &parent_old_tree_sha, - old_sha, - new_sha, - &entries, - ) - .await -} - -#[derive(Serialize)] -struct TreePayload<'a> { - base_tree: &'a str, - #[serde(borrow)] - tree: &'a [TreeEntry], -} - -#[derive(Serialize)] -struct CommitPayload<'a> { - message: String, - tree: &'a str, - parents: Vec<&'a str>, -} - -#[derive(Deserialize)] -struct ShaResponse { - sha: String, -} - -fn short(sha: &str) -> &str { - if sha.len() > 7 { &sha[..7] } else { sha } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::process::{Command, Stdio}; - - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = TempDir::new().unwrap(); - let path = dir.path(); - run(path, &["init", "-q", "-b", "main"]); - run(path, &["config", "user.email", "t@e.com"]); - run(path, &["config", "user.name", "t"]); - run(path, &["config", "commit.gpgsign", "false"]); - dir - } - - fn run(path: &Path, args: &[&str]) { - let status = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .status() - .unwrap(); - assert!(status.success(), "git {args:?} failed"); - } - - fn rev_parse(path: &Path, refname: &str) -> String { - run_git_capture(Some(path), &["rev-parse", refname]).unwrap() - } - - /// `git <args>` fed `stdin`, returning its trimmed stdout. - /// Lets the tests below plant objects whose path bytes the - /// filesystem would refuse (`mktree`) without going through a - /// worktree. - fn run_with_stdin(path: &Path, args: &[&str], stdin: &[u8]) -> String { - let mut child = Command::new("git") - .arg("-C") - .arg(path) - .args(args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.take().unwrap().write_all(stdin).unwrap(); - let out = child.wait_with_output().unwrap(); - assert!(out.status.success(), "git {args:?} failed"); - String::from_utf8(out.stdout).unwrap().trim().to_string() - } - - #[test] - fn mode_to_type_maps_known_modes() { - assert_eq!(mode_to_type("160000"), "commit"); - assert_eq!(mode_to_type("040000"), "tree"); - assert_eq!(mode_to_type("100644"), "blob"); - assert_eq!(mode_to_type("100755"), "blob"); - // Anything else falls back to blob so an unfamiliar mode - // doesn't crash the parser. - assert_eq!(mode_to_type(""), "blob"); - assert_eq!(mode_to_type("garbage"), "blob"); - } - - #[test] - fn compute_tree_delta_parses_modifications_and_deletions() { - // Drive against a real repo: build a base commit with - // three files, then a second commit that modifies one, - // deletes one, adds one, and type-changes one. The diff - // between their trees must produce the 4 expected - // entries. - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("modified.txt"), "v1\n").unwrap(); - std::fs::write(path.join("deleted.txt"), "bye\n").unwrap(); - std::fs::write(path.join("type-change.txt"), "regular\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "base"]); - let base_tree = rev_parse(path, "HEAD^{tree}"); - - std::fs::write(path.join("modified.txt"), "v2\n").unwrap(); - std::fs::remove_file(path.join("deleted.txt")).unwrap(); - std::fs::write(path.join("added.txt"), "new\n").unwrap(); - // Type change: regular file -> executable. Triggers the - // `T` status in diff-tree output. - let mut perms = std::fs::metadata(path.join("type-change.txt")) - .unwrap() - .permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path.join("type-change.txt"), perms).unwrap(); - run(path, &["add", "--all"]); - run(path, &["commit", "-q", "-m", "edit"]); - let new_tree = rev_parse(path, "HEAD^{tree}"); - - let entries = compute_tree_delta(Some(path), &base_tree, &new_tree).unwrap(); - - let by_path: std::collections::HashMap<_, _> = - entries.iter().map(|e| (e.path.as_str(), e)).collect(); - assert_eq!(by_path.len(), 4); - - let m = by_path["modified.txt"]; - assert_eq!(m.type_, "blob"); - assert_eq!(m.mode, "100644"); - assert!(m.sha.is_some()); - - let d = by_path["deleted.txt"]; - assert_eq!(d.type_, "blob"); - assert_eq!(d.mode, "100644"); - assert!(d.sha.is_none(), "deletions get null sha"); - - let a = by_path["added.txt"]; - assert_eq!(a.mode, "100644"); - assert!(a.sha.is_some()); - - let t = by_path["type-change.txt"]; - assert_eq!(t.mode, "100755", "type change exposes the new mode"); - } - - #[test] - fn compute_tree_delta_preserves_paths_git_would_quote() { - // MRGFY-8288: `core.quotePath` is on by default, so - // line-based output wrapped the accented path in quotes and - // octal-escaped it (`\303\251` for `é`), and that literal - // string went to the trees API, creating a file under the - // escaped name and leaving the real one untouched. The - // quote/backslash path is the half `core.quotePath=false` - // would not fix. The name ending in a space pins that only - // the meta field is whitespace-split: the path field is - // handed back byte for byte. - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("keep.txt"), "v1\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "base"]); - let base_tree = rev_parse(path, "HEAD^{tree}"); - - let awkward = ["caché.txt", "quote\"and\\slash.txt", "ends in a space "]; - for name in awkward { - std::fs::write(path.join(name), "new\n").unwrap(); - } - run(path, &["add", "--all"]); - run(path, &["commit", "-q", "-m", "add awkward names"]); - let new_tree = rev_parse(path, "HEAD^{tree}"); - - let mut paths: Vec<String> = compute_tree_delta(Some(path), &base_tree, &new_tree) - .unwrap() - .into_iter() - .map(|e| e.path) - .collect(); - paths.sort_unstable(); - let mut expected = awkward; - expected.sort_unstable(); - assert_eq!(paths, expected); - } - - #[test] - fn compute_tree_delta_errors_on_non_utf8_path() { - // Git holds paths as bytes; one that isn't UTF-8 cannot be - // uploaded to the JSON trees API under any encoding. - // `from_utf8_lossy` would re-create the MRGFY-8288 - // corruption (file lands under a mangled name, the - // original stays) and skipping the entry would silently - // under-apply the replay, so this errors and - // `replay_for_revision` falls back to the plain compare - // URL. Built with `mktree` because macOS refuses to create - // such a filename in a worktree. - let dir = init_repo(); - let path = dir.path(); - let blob = run_with_stdin(path, &["hash-object", "-w", "--stdin"], b"hello\n"); - // `mktree -z` entry: "<mode> SP <type> SP <sha> TAB <path> NUL". - // The path is `latin1-é.txt` as latin-1, i.e. a lone 0xe9 - // where UTF-8 would need two bytes. - let mut entry = format!("100644 blob {blob}\t").into_bytes(); - entry.extend_from_slice(b"latin1-\xe9.txt\0"); - let tree = run_with_stdin(path, &["mktree", "-z"], &entry); - let empty_tree = run_with_stdin(path, &["mktree", "-z"], b""); - - let err = compute_tree_delta(Some(path), &empty_tree, &tree).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("not valid UTF-8"), "{msg}"); - assert!(msg.contains(".txt"), "the offending entry is named: {msg}"); - } - - #[test] - fn compute_tree_delta_empty_when_no_diff() { - // Diffing a tree against itself produces no entries — - // callers treat this as "nothing to upload." - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("x"), "x\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "x"]); - let tree = rev_parse(path, "HEAD^{tree}"); - let entries = compute_tree_delta(Some(path), &tree, &tree).unwrap(); - assert!(entries.is_empty()); - } - - #[test] - fn compute_merged_tree_isolates_the_amendment_when_base_unchanged() { - // The bug this whole module fixes: a PR amended on a - // stable base. If we ever regress to the naive - // sibling-on-new-base replay, the merged tree would - // equal old_sha's tree and the diff against - // parent_old_sha^{tree} would include the *whole PR* - // rather than just the edit. - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("base.txt"), "base\n").unwrap(); - run(path, &["add", "base.txt"]); - run(path, &["commit", "-q", "-m", "base"]); - let base_sha = rev_parse(path, "HEAD"); - - // old PR head: adds routes.py. - std::fs::write(path.join("routes.py"), "# routes\n").unwrap(); - run(path, &["add", "routes.py"]); - run(path, &["commit", "-q", "-m", "add routes"]); - let old_sha = rev_parse(path, "HEAD"); - - // new PR head: same base, same routes.py blob, plus the - // amendment — a follow-up migration and an unrelated - // tweak to base.txt. - run(path, &["reset", "--hard", &base_sha]); - std::fs::write(path.join("routes.py"), "# routes\n").unwrap(); - std::fs::write(path.join("migration.py"), "# migration\n").unwrap(); - std::fs::write(path.join("base.txt"), "base\nindex\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "add routes + follow-up"]); - let new_sha = rev_parse(path, "HEAD"); - - let merged = compute_merged_tree(Some(path), &old_sha, &new_sha).expect("clean merge"); - assert_eq!(merged.parent_old_sha, base_sha); - - // Now the orientation invariant: diff between old_sha's - // tree and the merged tree must contain ONLY the - // amendment. routes.py was already in old_sha and must - // not appear. - let diff = run_git_capture( - Some(path), - &[ - "diff-tree", - "-r", - "--no-renames", - "--name-status", - &old_sha, - &merged.tree_sha, - ], - ) - .unwrap(); - let mut changed: Vec<&str> = diff - .lines() - .filter_map(|l| l.split_once('\t').map(|(_, p)| p)) - .collect(); - changed.sort_unstable(); - assert_eq!(changed, ["base.txt", "migration.py"]); - } - - #[test] - fn compute_merged_tree_returns_none_on_missing_parent() { - // Bogus SHAs — the rev-parse for `^` fails, classifier - // must fall back to None rather than propagating. - let dir = init_repo(); - std::fs::write(dir.path().join("x"), "x\n").unwrap(); - run(dir.path(), &["add", "x"]); - run(dir.path(), &["commit", "-q", "-m", "x"]); - // The single commit has no parent — `HEAD^` rev-parse - // fails, which is exactly the "missing parents" case - // we're guarding against. - let head = rev_parse(dir.path(), "HEAD"); - assert!(compute_merged_tree(Some(dir.path()), &head, &head).is_none()); - } - - #[test] - fn compute_merged_tree_returns_none_on_conflict() { - // Replay conflict shape: parent_old and parent_new - // disagree on a line that new_sha also edits, so the - // 3-way `merge-tree --merge-base=parent_new parent_old - // new_sha` exits non-zero and the classifier returns - // None. - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("x"), "base\n").unwrap(); - run(path, &["add", "x"]); - run(path, &["commit", "-q", "-m", "base"]); - let base = rev_parse(path, "HEAD"); - - // old branch: parent_old sets x="old", old_sha just adds y. - std::fs::write(path.join("x"), "old\n").unwrap(); - run(path, &["add", "x"]); - run(path, &["commit", "-q", "-m", "parent_old"]); - std::fs::write(path.join("y"), "y\n").unwrap(); - run(path, &["add", "y"]); - run(path, &["commit", "-q", "-m", "old"]); - let old_sha = rev_parse(path, "HEAD"); - - // new branch: parent_new sets x="new", new_sha edits x - // to "new_amended". When merge-tree replays (new_sha − - // parent_new) onto parent_old, both sides edit x — - // conflict. - run(path, &["reset", "--hard", &base]); - std::fs::write(path.join("x"), "new\n").unwrap(); - run(path, &["add", "x"]); - run(path, &["commit", "-q", "-m", "parent_new"]); - std::fs::write(path.join("x"), "new_amended\n").unwrap(); - run(path, &["add", "x"]); - run(path, &["commit", "-q", "-m", "new"]); - let new_sha = rev_parse(path, "HEAD"); - - assert!(compute_merged_tree(Some(path), &old_sha, &new_sha).is_none()); - } - - #[test] - fn tree_entry_serialises_with_renamed_type_and_null_sha() { - // Wire-shape contract with GitHub's `POST /git/trees`: - // the field is `"type"` (not Rust's reserved `type_`) - // and a deletion's sha serialises to JSON `null`. - let e = TreeEntry { - path: "a.py".to_string(), - mode: "100644".to_string(), - type_: "blob".to_string(), - sha: None, - }; - let json = serde_json::to_value(&e).unwrap(); - assert_eq!(json["type"], "blob"); - assert_eq!(json["sha"], serde_json::Value::Null); - assert_eq!(json["path"], "a.py"); - assert_eq!(json["mode"], "100644"); - } - - mod http { - use super::*; - use mergify_core::{ApiFlavor, HttpClient}; - use serde_json::json; - use url::Url; - use wiremock::matchers::{method, path as wm_path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "token", - ApiFlavor::GitHub, - ) - .unwrap() - } - - fn one_entry() -> Vec<TreeEntry> { - vec![TreeEntry { - path: "src/a.py".into(), - mode: "100644".into(), - type_: "blob".into(), - sha: Some("bbb2222".into()), - }] - } - - #[tokio::test] - async fn upload_replay_commit_chains_tree_then_commit() { - // Two-call shape — body contract: tree POST carries - // `base_tree` + `tree` entries; commit POST carries - // `parents=[old_sha]` so the GitHub compare URL - // anchors on it. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/trees")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"sha": "server_tree"})), - ) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/commits")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"sha": "server_commit"})), - ) - .mount(&server) - .await; - - let entries = one_entry(); - let sha = upload_replay_commit( - &client(&server), - "o", - "r", - "parent_old_tree_sha", - "abc1234deadbeef", - "def5678cafef00d", - &entries, - ) - .await; - assert_eq!(sha.as_deref(), Some("server_commit")); - } - - #[tokio::test] - async fn upload_replay_commit_returns_none_when_tree_post_fails() { - // 422 on the tree POST — short-circuit, no commit - // POST. Python catches the error and returns None; - // mirror that so a transient API hiccup doesn't - // crash the push. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/trees")) - .respond_with(ResponseTemplate::new(422)) - .mount(&server) - .await; - - assert!( - upload_replay_commit( - &client(&server), - "o", - "r", - "base", - "old", - "new", - &one_entry(), - ) - .await - .is_none(), - ); - } - - #[tokio::test] - async fn upload_replay_commit_returns_none_when_commit_post_fails() { - // Tree POST succeeds, commit POST fails — still None. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/trees")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"sha": "server_tree"})), - ) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/commits")) - .respond_with(ResponseTemplate::new(422)) - .mount(&server) - .await; - - assert!( - upload_replay_commit( - &client(&server), - "o", - "r", - "base", - "old", - "new", - &one_entry(), - ) - .await - .is_none(), - ); - } - - #[tokio::test] - async fn replay_for_revision_end_to_end_returns_server_commit_sha() { - // Drive the orchestrator against a real repo + a - // mock GitHub: clean merge, real diff, both POSTs - // succeed → returns the server commit SHA. - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/trees")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"sha": "server_tree"})), - ) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(wm_path("/repos/o/r/git/commits")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"sha": "server_commit"})), - ) - .mount(&server) - .await; - - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("base.txt"), "base\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "base"]); - let base_sha = rev_parse(path, "HEAD"); - - std::fs::write(path.join("routes.py"), "# routes\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "add"]); - let old_sha = rev_parse(path, "HEAD"); - - run(path, &["reset", "--hard", &base_sha]); - std::fs::write(path.join("routes.py"), "# routes\n").unwrap(); - std::fs::write(path.join("migration.py"), "# migration\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "add + amend"]); - let new_sha = rev_parse(path, "HEAD"); - - let sha = - replay_for_revision(&client(&server), Some(path), "o", "r", &old_sha, &new_sha) - .await; - assert_eq!(sha.as_deref(), Some("server_commit")); - } - - #[tokio::test] - async fn replay_for_revision_returns_none_when_diff_is_empty() { - // If the merged tree equals parent_old's tree there's - // nothing to upload — short-circuit before any POST. - // No mocks needed: any POST attempt would 404 and - // turn into Some(...).is_none() == false. - let server = MockServer::start().await; - let dir = init_repo(); - let path = dir.path(); - std::fs::write(path.join("x"), "x\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "base"]); - let base_sha = rev_parse(path, "HEAD"); - - // Identical old/new commits (each child of base, both - // adding the same blob): merge-tree returns - // base_tree, diff against parent_old^{tree} is empty. - std::fs::write(path.join("y"), "y\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "child"]); - let old_sha = rev_parse(path, "HEAD"); - run(path, &["reset", "--hard", &base_sha]); - std::fs::write(path.join("y"), "y\n").unwrap(); - run(path, &["add", "."]); - run(path, &["commit", "-q", "-m", "child"]); - let new_sha = rev_parse(path, "HEAD"); - - assert!( - replay_for_revision(&client(&server), Some(path), "o", "r", &old_sha, &new_sha,) - .await - .is_none(), - ); - } - } -} diff --git a/crates/mergify-stack/src/revision_history.rs b/crates/mergify-stack/src/revision_history.rs deleted file mode 100644 index cdec2057..00000000 --- a/crates/mergify-stack/src/revision_history.rs +++ /dev/null @@ -1,1040 +0,0 @@ -//! The "Revision history" sticky comment posted on every PR -//! `mergify stack push` updates. -//! -//! Each revision adds one table row pinning *what changed* -//! (`rebase` / `content` / `unknown`) and *why* (the amend -//! `reason` attached via `mergify stack note`). The body has -//! three parts: -//! -//! 1. A fixed first-line header. -//! 2. A markdown table with one row per revision. -//! 3. A single-line `<!-- mergify-revision-data: {…} -->` -//! marker that carries the *machine-readable* version of the -//! same data, including the SHAs the table-cell rendering -//! only shows in truncated form. This same marker line is -//! also written into the git note on the pushed head commit -//! (see [`crate::revision_note`]), which is now the machine -//! source subsequent pushes read to build the next revision. -//! `parse()` still reads the marker back off the comment -//! body — it remains the one-time migration seed for stacks -//! last pushed before notes carried history, and keeps -//! legacy comments round-tripping. -//! -//! Ported from `mergify_cli/stack/push.py::RevisionHistoryComment`. -//! Wire shape — table format, marker JSON, ISO-8601 timestamps -//! — is contract: live PRs carry historic comments produced by -//! the Python implementation and `parse()` has to round-trip -//! them. -//! -//! Compare URLs: when a `replay_sha` is available (synthetic -//! commit whose tree is the user's amendment isolated from -//! rebase noise — see [`crate::replay`]) the row's compare URL -//! anchors at it for the headline link and falls back to the -//! raw `old→new` URL in a parenthetical `(raw)` for when the -//! synth commit gets GC'd by GitHub. Both URLs use `old_sha` -//! as the merge-base for the diff. - -use std::fmt::Write; -use std::sync::LazyLock; - -use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; -use regex::Regex; -use serde::{Deserialize, Serialize}; - -use crate::change_type::ChangeType; - -/// First-line header used to recognise our sticky comment. The -/// trailing `\n` is part of the contract — the matcher uses -/// `starts_with`, so a body that opens with `### Revision -/// history` *followed by other content on the same line* is -/// **not** ours. -pub const REVISION_COMMENT_FIRST_LINE: &str = "### Revision history\n"; - -const MARKER_PREFIX: &str = "<!-- mergify-revision-data: "; -const MARKER_SUFFIX: &str = " -->"; - -/// Neutralise the HTML-comment delimiters inside a serialised -/// marker payload. -/// -/// The marker rides inside `<!-- … -->`, and a renderer closes an -/// HTML comment at the *first* `-->`. A `reason` that quotes one -/// therefore ends the marker early and spills the rest of the -/// JSON into the rendered comment as visible text. `\u003c` and -/// `\u003e` are the JSON escapes for `<` and `>`, so the payload -/// decodes to the exact same string — [`parse_marker_line`] -/// round-trips it, and markers written before this escape keep -/// parsing unchanged. -fn hide_html_comment_delimiters(json: &str) -> String { - json.replace("-->", "--\\u003e") - .replace("<!--", "\\u003c!--") -} - -/// Soft cap on the reason cell. Long single-line reasons get an -/// ellipsis to keep the table readable on GitHub. Python uses 200; -/// keep the same number so re-renders of historic rows match. -const MAX_REASON_LEN: usize = 200; - -pub(crate) const TIMESTAMP_HUMAN_FMT: &str = "%Y-%m-%d %H:%M UTC"; -const TIMESTAMP_ISO_FMT: &str = "%Y-%m-%dT%H:%M:%SZ"; - -/// Escape `reason` for a single markdown table cell. -/// -/// - `\` → `\\` (must come first to avoid double-escaping the -/// next two replacements) -/// - `|` → `\|` (column separator) -/// - `\n` → `<br>` (cells can't span lines on GitHub) -/// - Truncate to [`MAX_REASON_LEN`] with a trailing `…` -/// -/// Empty input short-circuits to `""` so the cell is visually -/// empty rather than carrying any placeholder. -#[must_use] -pub fn escape_reason(reason: &str) -> String { - if reason.is_empty() { - return String::new(); - } - let escaped = reason - .replace('\\', "\\\\") - .replace('|', "\\|") - .replace('\n', "<br>"); - if escaped.chars().count() > MAX_REASON_LEN { - let mut out: String = escaped.chars().take(MAX_REASON_LEN - 1).collect(); - out.push('…'); - out - } else { - escaped - } -} - -/// One row in the revision-history table plus one entry in the -/// JSON marker. `old_sha` is `None` only for the synthetic -/// `initial` row that anchors the first PR head (no prior SHA to -/// compare against). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RevisionEntry { - pub number: u32, - pub change_type: ChangeType, - pub old_sha: Option<String>, - pub new_sha: String, - pub timestamp: Option<DateTime<Utc>>, - pub reason: String, - /// Synth commit from [`crate::replay`]. When present, used - /// for the headline compare URL so the diff renders the - /// user's amendment without rebase noise; the raw - /// `old→new` URL still gets emitted as the durable - /// fallback. - pub replay_sha: Option<String>, -} - -impl RevisionEntry { - fn timestamp_human(&self) -> String { - self.timestamp - .as_ref() - .map_or_else(String::new, |t| t.format(TIMESTAMP_HUMAN_FMT).to_string()) - } - - fn timestamp_iso(&self) -> Option<String> { - self.timestamp - .as_ref() - .map(|t| t.format(TIMESTAMP_ISO_FMT).to_string()) - } -} - -/// The whole revision-history sticky comment for one PR. -/// -/// Build via [`Self::create_initial`] for a fresh comment, or via -/// [`Self::parse`] when re-rendering an existing one (which -/// preserves the original markdown rows verbatim — useful so a -/// re-render of a historic table doesn't whiplash old links). -#[derive(Debug, Clone)] -pub struct RevisionHistoryComment { - pub github_server: String, - pub user: String, - pub repo: String, - pub entries: Vec<RevisionEntry>, - /// Original raw rows from a [`Self::parse`]d comment, one - /// per parsed entry. Rendering uses these verbatim when - /// present so historic links stay intact across re-renders. - raw_rows: Vec<String>, -} - -impl RevisionHistoryComment { - /// True if `comment_body` looks like our revision-history - /// sticky comment. - #[must_use] - pub fn is_revision_comment(comment_body: &str) -> bool { - comment_body.starts_with(REVISION_COMMENT_FIRST_LINE) - } - - /// Build the initial 2-row comment: a synthetic `initial` - /// row anchoring `old_sha` and the first real revision row - /// for the `old → new` change. - #[must_use] - #[allow(clippy::too_many_arguments)] - pub fn create_initial( - github_server: &str, - user: &str, - repo: &str, - old_sha: &str, - new_sha: &str, - change_type: ChangeType, - timestamp: DateTime<Utc>, - reason: &str, - replay_sha: Option<&str>, - ) -> Self { - let entries = vec![ - RevisionEntry { - number: 1, - change_type: ChangeType::Initial, - old_sha: None, - new_sha: old_sha.to_string(), - timestamp: Some(timestamp), - reason: String::new(), - replay_sha: None, - }, - RevisionEntry { - number: 2, - change_type, - old_sha: Some(old_sha.to_string()), - new_sha: new_sha.to_string(), - timestamp: Some(timestamp), - reason: reason.to_string(), - replay_sha: replay_sha.map(str::to_owned), - }, - ]; - Self { - github_server: github_server.to_string(), - user: user.to_string(), - repo: repo.to_string(), - entries, - raw_rows: Vec::new(), - } - } - - /// Append a revision row. Number is auto-assigned. - pub fn append( - &mut self, - old_sha: &str, - new_sha: &str, - change_type: ChangeType, - timestamp: DateTime<Utc>, - reason: &str, - replay_sha: Option<&str>, - ) { - let next_number = u32::try_from(self.entries.len() + 1).unwrap_or(u32::MAX); - self.entries.push(RevisionEntry { - number: next_number, - change_type, - old_sha: Some(old_sha.to_string()), - new_sha: new_sha.to_string(), - timestamp: Some(timestamp), - reason: reason.to_string(), - replay_sha: replay_sha.map(str::to_owned), - }); - } - - fn compare_url(&self, old_sha: &str, new_sha: &str) -> String { - let api_url = self.github_server.trim_end_matches('/'); - let html_url = if api_url.contains("/api/v3") { - api_url.replace("/api/v3", "") - } else { - api_url.replace("api.github.com", "github.com") - }; - format!( - "{html_url}/{user}/{repo}/compare/{old_sha}...{new_sha}", - user = self.user, - repo = self.repo, - ) - } - - fn entry_head_sha(entry: &RevisionEntry) -> &str { - entry.replay_sha.as_deref().unwrap_or(&entry.new_sha) - } - - fn render_entry(&self, entry: &RevisionEntry) -> String { - let changes_cell = match (&entry.old_sha, entry.change_type) { - (None, _) => format!("`{}`", short_sha(&entry.new_sha)), - (Some(old), ChangeType::Rebase) => { - // Patch-id matched — no semantic edit, so no - // compare URL (it would just show rebase noise). - format!( - "`{} \u{2192} {}` _(rebase only)_", - short_sha(old), - short_sha(&entry.new_sha), - ) - } - (Some(old), _) => { - let url = self.compare_url(old, Self::entry_head_sha(entry)); - let mut cell = format!( - "[`{} \u{2192} {}`]({url})", - short_sha(old), - short_sha(&entry.new_sha), - ); - if entry.replay_sha.is_some() { - // Synth replay commits get GC'd by GitHub - // eventually; pair with the always-resolvable - // raw `old→new` URL so the row stays useful - // long-term. - let raw_url = self.compare_url(old, &entry.new_sha); - write!(cell, " ([raw]({raw_url}))").expect("string"); - } - cell - } - }; - format!( - "| {} | {} | {} | {} | {} |", - entry.number, - entry.change_type.as_str(), - changes_cell, - escape_reason(&entry.reason), - entry.timestamp_human(), - ) - } - - fn json_marker(&self, pull_number: u64) -> String { - let entries: Vec<MarkerEntry<'_>> = self - .entries - .iter() - .map(|e| MarkerEntry { - number: e.number, - change_type: e.change_type.as_str(), - old_sha: e.old_sha.as_deref(), - new_sha: &e.new_sha, - timestamp_iso: e.timestamp_iso(), - reason: &e.reason, - replay_sha: e.replay_sha.as_deref(), - compare_url: e - .old_sha - .as_deref() - .map(|old| self.compare_url(old, Self::entry_head_sha(e))), - }) - .collect(); - let payload = MarkerPayload { - schema_version: 1, - pull_number, - entries, - }; - let json = serde_json::to_string(&payload).expect("MarkerPayload always serialises"); - let json = hide_html_comment_delimiters(&json); - format!("{MARKER_PREFIX}{json}{MARKER_SUFFIX}") - } - - /// The `<!-- mergify-revision-data: {…} -->` line alone — the - /// machine-readable history. Written both at the bottom of the - /// PR comment and into the git note on the pushed head commit - /// (see [`crate::revision_note`]), so there is exactly one - /// schema for revision data. - #[must_use] - pub fn marker_line(&self, pull_number: u64) -> String { - self.json_marker(pull_number) - } - - /// Rebuild a history from bare entries (the note-based read - /// path). No `raw_rows`: rows render fresh from entry data. - #[must_use] - pub fn from_entries( - github_server: &str, - user: &str, - repo: &str, - entries: Vec<RevisionEntry>, - ) -> Self { - Self { - github_server: github_server.to_string(), - user: user.to_string(), - repo: repo.to_string(), - entries, - raw_rows: Vec::new(), - } - } - - /// Render the full comment body for `pull_number`. - /// - /// Rows preserved from [`Self::parse`] are emitted verbatim - /// so historic links don't change shape on a re-render. New - /// rows (appended via [`Self::append`] or seeded by - /// [`Self::create_initial`]) get rendered fresh. - #[must_use] - pub fn body(&self, pull_number: u64) -> String { - let mut out = String::with_capacity(256 + self.entries.len() * 96); - out.push_str(REVISION_COMMENT_FIRST_LINE); - // Python's body builds `"\n".join([header, …])` where the - // header already has a trailing `\n`, producing a blank - // line between header and table. Reproduce that gap so - // re-renders of historic comments don't shift bytes. - out.push('\n'); - out.push_str("| # | Type | Changes | Reason | Date |\n"); - out.push_str("|---|------|---------|--------|------|\n"); - for (i, entry) in self.entries.iter().enumerate() { - if let Some(row) = self.raw_rows.get(i) { - out.push_str(row); - } else { - out.push_str(&self.render_entry(entry)); - } - out.push('\n'); - } - out.push_str(&self.json_marker(pull_number)); - out.push('\n'); - out - } - - /// Parse an existing revision-history comment back into a - /// `RevisionHistoryComment`. Returns `None` if `body` isn't - /// shaped like our sticky comment. - /// - /// The table rows alone don't carry SHAs (they're truncated - /// for readability); the JSON marker does. Parsing walks - /// rows first to seed the entry list with `number` + - /// `change_type` + parsed timestamp, then overlays the - /// marker payload onto the same entries so old/new SHAs, - /// reasons, and replay SHAs come through. - #[must_use] - pub fn parse(body: &str, github_server: &str, user: &str, repo: &str) -> Option<Self> { - if !body.starts_with(REVISION_COMMENT_FIRST_LINE) { - return None; - } - - let mut entries: Vec<RevisionEntry> = Vec::new(); - let mut raw_rows: Vec<String> = Vec::new(); - let mut marker_entries: Option<Vec<serde_json::Value>> = None; - // A line that *starts* with the marker prefix but fails - // to round-trip is corruption, not absence — the - // upserter recovers from `None` but happily PATCHes a - // `Some(_)` with empty SHAs, losing the historical - // payload. Track it separately so we can short-circuit. - let mut marker_corrupted = false; - - for line in body.lines() { - // Try the 5-column shape first (current rendering), - // then fall back to the 4-column legacy shape — old - // comments out in the wild still need to round-trip. - if let Some((number, change_type, timestamp)) = match_row_5(line) { - entries.push(RevisionEntry { - number, - change_type, - old_sha: None, - new_sha: String::new(), - timestamp, - reason: String::new(), - replay_sha: None, - }); - raw_rows.push(line.to_string()); - continue; - } - if let Some((number, change_type, timestamp)) = match_row_4(line) { - entries.push(RevisionEntry { - number, - change_type, - old_sha: None, - new_sha: String::new(), - timestamp, - reason: String::new(), - replay_sha: None, - }); - raw_rows.push(line.to_string()); - continue; - } - if let Some(payload) = parse_marker_line(line) { - if let Some(v) = payload.get("schema_version") - && v.as_u64() == Some(1) - && let Some(arr) = payload.get("entries").and_then(|v| v.as_array()) - { - marker_entries = Some(arr.clone()); - } else { - marker_corrupted = true; - } - } else if line.starts_with(MARKER_PREFIX) { - marker_corrupted = true; - } - } - - if entries.is_empty() || marker_corrupted { - return None; - } - - if let Some(marker) = marker_entries - && marker.len() == entries.len() - { - for (entry, data) in entries.iter_mut().zip(marker.iter()) { - let Some(obj) = data.as_object() else { - continue; - }; - if obj.get("number").and_then(serde_json::Value::as_u64) - != Some(u64::from(entry.number)) - { - continue; - } - match obj.get("old_sha") { - Some(serde_json::Value::String(s)) => entry.old_sha = Some(s.clone()), - Some(serde_json::Value::Null) => entry.old_sha = None, - _ => {} - } - if let Some(serde_json::Value::String(s)) = obj.get("new_sha") { - entry.new_sha.clone_from(s); - } - match obj.get("timestamp_iso") { - Some(serde_json::Value::String(s)) => { - if let Ok(naive) = NaiveDateTime::parse_from_str(s, TIMESTAMP_ISO_FMT) { - entry.timestamp = Some(Utc.from_utc_datetime(&naive)); - } - } - Some(serde_json::Value::Null) => entry.timestamp = None, - _ => {} - } - if let Some(serde_json::Value::String(s)) = obj.get("reason") { - entry.reason.clone_from(s); - } - match obj.get("replay_sha") { - Some(serde_json::Value::String(s)) => entry.replay_sha = Some(s.clone()), - Some(serde_json::Value::Null) => entry.replay_sha = None, - _ => {} - } - } - } - - Some(Self { - github_server: github_server.to_string(), - user: user.to_string(), - repo: repo.to_string(), - entries, - raw_rows, - }) - } -} - -fn short_sha(sha: &str) -> &str { - if sha.len() > 7 { &sha[..7] } else { sha } -} - -/// 5-column row regex: `| n | type | changes | reason | date |`. -/// `reason` is captured but the parsed entry leaves it empty — -/// the marker is the source of truth for the unescaped reason. -static ROW_RE_5: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"^\| (\d+) \| (\w+) \| .+ \| (.*) \| (.+) \|$").unwrap()); - -/// 4-column legacy row: `| n | type | changes | date |`. Older -/// comments lack the reason column. -static ROW_RE_4: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"^\| (\d+) \| (\w+) \| .+ \| (.+) \|$").unwrap()); - -static MARKER_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(&format!( - r"^{}(?P<payload>\{{.*\}}){}$", - regex::escape(MARKER_PREFIX), - regex::escape(MARKER_SUFFIX), - )) - .unwrap() -}); - -fn match_row_5(line: &str) -> Option<(u32, ChangeType, Option<DateTime<Utc>>)> { - let caps = ROW_RE_5.captures(line)?; - let number = caps.get(1)?.as_str().parse().ok()?; - let change_type = ChangeType::from_str_lossy(caps.get(2)?.as_str()); - let timestamp = parse_human_timestamp(caps.get(4)?.as_str().trim()); - Some((number, change_type, timestamp)) -} - -fn match_row_4(line: &str) -> Option<(u32, ChangeType, Option<DateTime<Utc>>)> { - let caps = ROW_RE_4.captures(line)?; - let number = caps.get(1)?.as_str().parse().ok()?; - let change_type = ChangeType::from_str_lossy(caps.get(2)?.as_str()); - let timestamp = parse_human_timestamp(caps.get(3)?.as_str().trim()); - Some((number, change_type, timestamp)) -} - -fn parse_human_timestamp(s: &str) -> Option<DateTime<Utc>> { - let naive = NaiveDateTime::parse_from_str(s, TIMESTAMP_HUMAN_FMT).ok()?; - Some(Utc.from_utc_datetime(&naive)) -} - -/// Parse one `<!-- mergify-revision-data: {…} -->` line into its -/// JSON payload. Public because the git-notes history (see -/// [`crate::revision_note`]) carries the exact same marker line. -#[must_use] -pub fn parse_marker_line(line: &str) -> Option<serde_json::Value> { - let caps = MARKER_RE.captures(line)?; - serde_json::from_str(caps.name("payload")?.as_str()).ok() -} - -/// True when any line of `text` is a revision-data marker — used -/// to tell a machine history note apart from a plain amend -/// reason written by `mergify stack note`. -#[must_use] -pub fn contains_marker(text: &str) -> bool { - text.lines().any(|l| l.starts_with(MARKER_PREFIX)) -} - -/// Decode a marker payload into full [`RevisionEntry`] values. -/// -/// Unlike [`RevisionHistoryComment::parse`] — which overlays the -/// marker onto entries seeded from the rendered table rows — this -/// builds entries from the marker alone (the note has no table). -/// Returns `None` for a wrong `schema_version`, an empty entry -/// list, or any entry missing its required fields: a truncated -/// history must not silently pass for the real one. -#[must_use] -pub fn entries_from_marker(payload: &serde_json::Value) -> Option<Vec<RevisionEntry>> { - if payload - .get("schema_version") - .and_then(serde_json::Value::as_u64) - != Some(1) - { - return None; - } - let arr = payload.get("entries")?.as_array()?; - if arr.is_empty() { - return None; - } - let mut entries = Vec::with_capacity(arr.len()); - for item in arr { - let obj = item.as_object()?; - let number = u32::try_from(obj.get("number").and_then(serde_json::Value::as_u64)?).ok()?; - let change_type = - ChangeType::from_str_lossy(obj.get("change_type").and_then(serde_json::Value::as_str)?); - let old_sha = match obj.get("old_sha") { - Some(serde_json::Value::String(s)) => Some(s.clone()), - _ => None, - }; - // Present-but-empty is as invalid as absent: an entry - // with no head SHA can't anchor a revision. - let new_sha = obj - .get("new_sha") - .and_then(serde_json::Value::as_str) - .filter(|s| !s.is_empty())? - .to_string(); - let timestamp = obj - .get("timestamp_iso") - .and_then(serde_json::Value::as_str) - .and_then(|s| NaiveDateTime::parse_from_str(s, TIMESTAMP_ISO_FMT).ok()) - .map(|naive| Utc.from_utc_datetime(&naive)); - let reason = obj - .get("reason") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - let replay_sha = match obj.get("replay_sha") { - Some(serde_json::Value::String(s)) => Some(s.clone()), - _ => None, - }; - entries.push(RevisionEntry { - number, - change_type, - old_sha, - new_sha, - timestamp, - reason, - replay_sha, - }); - } - Some(entries) -} - -#[derive(Serialize, Deserialize)] -struct MarkerEntry<'a> { - number: u32, - change_type: &'a str, - old_sha: Option<&'a str>, - new_sha: &'a str, - timestamp_iso: Option<String>, - reason: &'a str, - replay_sha: Option<&'a str>, - compare_url: Option<String>, -} - -#[derive(Serialize, Deserialize)] -struct MarkerPayload<'a> { - schema_version: u8, - pull_number: u64, - #[serde(borrow)] - entries: Vec<MarkerEntry<'a>>, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn t() -> DateTime<Utc> { - Utc.with_ymd_and_hms(2026, 6, 4, 12, 30, 0).unwrap() - } - - #[test] - fn escape_reason_handles_backslash_pipe_newline_and_truncation() { - assert_eq!(escape_reason(""), ""); - assert_eq!(escape_reason("a|b"), "a\\|b"); - assert_eq!(escape_reason("a\nb"), "a<br>b"); - // Backslash must be doubled FIRST so the pipe/newline - // replacements don't double-escape it. - assert_eq!(escape_reason("a\\b"), "a\\\\b"); - // Truncation: MAX_REASON_LEN - 1 chars + "…". - let long = "x".repeat(MAX_REASON_LEN + 50); - let out = escape_reason(&long); - assert_eq!(out.chars().count(), MAX_REASON_LEN); - assert!(out.ends_with('…')); - } - - #[test] - fn compare_url_strips_api_v3_for_ghe_and_api_dot_github_dot_com_for_dotcom() { - let c = RevisionHistoryComment { - github_server: "https://api.github.com".into(), - user: "o".into(), - repo: "r".into(), - entries: Vec::new(), - raw_rows: Vec::new(), - }; - assert_eq!( - c.compare_url("aaa", "bbb"), - "https://github.com/o/r/compare/aaa...bbb", - ); - - let ghe = RevisionHistoryComment { - github_server: "https://ghe.example.com/api/v3".into(), - user: "o".into(), - repo: "r".into(), - entries: Vec::new(), - raw_rows: Vec::new(), - }; - assert_eq!( - ghe.compare_url("aaa", "bbb"), - "https://ghe.example.com/o/r/compare/aaa...bbb", - ); - - // Trailing slash on the API URL must not double up. - let trailing = RevisionHistoryComment { - github_server: "https://api.github.com/".into(), - user: "o".into(), - repo: "r".into(), - entries: Vec::new(), - raw_rows: Vec::new(), - }; - assert_eq!( - trailing.compare_url("aaa", "bbb"), - "https://github.com/o/r/compare/aaa...bbb", - ); - } - - #[test] - fn body_renders_initial_and_rebase_and_content_rows() { - let mut c = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "1234567abcdef", - "abcdef1234567", - ChangeType::Content, - t(), - "review feedback", - None, - ); - c.append( - "abcdef1234567", - "fedcba7654321", - ChangeType::Rebase, - t(), - "", - None, - ); - - let body = c.body(42); - - // Initial row: bare short SHA, no compare link. - assert!(body.contains("| 1 | initial | `1234567` | | 2026-06-04 12:30 UTC |")); - // Content row: link to compare URL using old → new. - assert!(body.contains( - "| 2 | content | [`1234567 \u{2192} abcdef1`](https://github.com/o/r/compare/1234567abcdef...abcdef1234567) | review feedback | 2026-06-04 12:30 UTC |" - )); - // Rebase row: no URL — patch-id matched so the diff - // would be empty/noise. - assert!(body.contains( - "| 3 | rebase | `abcdef1 \u{2192} fedcba7` _(rebase only)_ | | 2026-06-04 12:30 UTC |" - )); - } - - #[test] - fn body_replay_sha_is_used_for_headline_url_with_raw_fallback() { - // A row with `replay_sha` set must use it for the - // headline `[old → new](url)` link (so the compare - // renders just the amendment) AND surface the raw - // `old → new` URL in a `(raw)` parenthetical for the - // long-term-durable fallback. - let c = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "1234567abcdef", - "abcdef1234567", - ChangeType::Content, - t(), - "edit", - Some("ffffffffffffff"), - ); - let body = c.body(42); - assert!(body.contains( - "[`1234567 \u{2192} abcdef1`](https://github.com/o/r/compare/1234567abcdef...ffffffffffffff) ([raw](https://github.com/o/r/compare/1234567abcdef...abcdef1234567))" - )); - } - - #[test] - fn json_marker_contains_compare_url_and_replay_sha_for_each_entry() { - let c = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "1234567abcdef", - "abcdef1234567", - ChangeType::Content, - t(), - "edit", - Some("ffffffffffffff"), - ); - let body = c.body(42); - let line = body - .lines() - .find(|l| l.starts_with(MARKER_PREFIX)) - .expect("marker"); - assert!(line.ends_with(MARKER_SUFFIX)); - let payload = &line[MARKER_PREFIX.len()..line.len() - MARKER_SUFFIX.len()]; - let v: serde_json::Value = serde_json::from_str(payload).unwrap(); - assert_eq!(v["schema_version"], 1); - assert_eq!(v["pull_number"], 42); - assert_eq!(v["entries"][0]["change_type"], "initial"); - assert_eq!(v["entries"][0]["old_sha"], serde_json::Value::Null); - assert_eq!(v["entries"][0]["compare_url"], serde_json::Value::Null); - assert_eq!(v["entries"][1]["change_type"], "content"); - assert_eq!(v["entries"][1]["replay_sha"], "ffffffffffffff"); - // compare_url in the marker uses replay_sha when present, - // matching the headline link. - assert_eq!( - v["entries"][1]["compare_url"], - "https://github.com/o/r/compare/1234567abcdef...ffffffffffffff", - ); - assert_eq!(v["entries"][1]["timestamp_iso"], "2026-06-04T12:30:00Z"); - } - - #[test] - fn is_revision_comment_matches_first_line_only() { - assert!(RevisionHistoryComment::is_revision_comment( - "### Revision history\nbody", - )); - assert!(!RevisionHistoryComment::is_revision_comment( - "### Revision history details", // not the literal header - )); - assert!(!RevisionHistoryComment::is_revision_comment( - "Some other comment", - )); - } - - #[test] - fn parse_round_trips_a_rendered_body() { - // Build, render, parse → the parsed instance must - // produce the same body bytes. - let mut c = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "1234567abcdef", - "abcdef1234567", - ChangeType::Content, - t(), - "fix lint", - None, - ); - c.append( - "abcdef1234567", - "fedcba7654321", - ChangeType::Rebase, - t(), - "", - None, - ); - let body = c.body(42); - - let parsed = RevisionHistoryComment::parse(&body, "https://api.github.com", "o", "r") - .expect("parses back"); - assert_eq!(parsed.entries.len(), 3); - // Entries carry the marker-sourced SHAs + reasons + - // timestamps. - assert_eq!(parsed.entries[0].old_sha, None); - assert_eq!(parsed.entries[0].new_sha, "1234567abcdef"); - assert_eq!(parsed.entries[1].reason, "fix lint"); - assert_eq!(parsed.entries[1].change_type, ChangeType::Content); - assert_eq!(parsed.entries[2].change_type, ChangeType::Rebase); - - // Re-render preserves the original rows byte-for-byte. - // Round-trip stability matters: future pushes that add - // a new row mustn't whiplash old links. - let rerendered = parsed.body(42); - assert_eq!(rerendered, body); - } - - #[test] - fn parse_returns_none_for_non_revision_comments() { - assert!( - RevisionHistoryComment::parse( - "Some other comment body", - "https://api.github.com", - "o", - "r", - ) - .is_none() - ); - } - - #[test] - fn parse_returns_none_when_marker_line_is_corrupted() { - // A line that *starts* with the marker prefix but fails - // to parse (truncated JSON, hand-edited payload, etc.) - // must be reported as corruption so the upserter's - // recovery path (overwrite with a fresh initial comment) - // can fire — otherwise the next render would emit a new - // marker built from rows with empty `old_sha/new_sha` - // and the historical SHA payload would be lost. - let body = "### Revision history\n\ - | # | Type | Changes | Date | Reason |\n\ - |---|------|---------|------|--------|\n\ - | 1 | initial | `1234567` | 2026-06-04 12:30 UTC | |\n\ - <!-- mergify-revision-data: {\"schema_version\": -->\n"; - assert!(RevisionHistoryComment::parse(body, "https://api.github.com", "o", "r").is_none()); - } - - #[test] - fn parse_handles_4_column_legacy_rows_when_marker_missing() { - // Old comments out in the wild have 4 columns (no - // reason cell). Parsing must still seed the table rows - // even without a marker so a subsequent append can - // render a fresh 5-column row underneath them. - let body = "### Revision history\n\ - | # | Type | Changes | Date |\n\ - |---|------|---------|------|\n\ - | 1 | initial | `1234567` | 2026-06-04 12:30 UTC |\n\ - | 2 | content | `1234567 \u{2192} abcdef1` | 2026-06-04 12:30 UTC |\n"; - let parsed = RevisionHistoryComment::parse(body, "https://api.github.com", "o", "r") - .expect("parses"); - assert_eq!(parsed.entries.len(), 2); - assert_eq!(parsed.entries[0].change_type, ChangeType::Initial); - assert_eq!(parsed.entries[1].change_type, ChangeType::Content); - // No marker so SHAs stay empty on the parsed entries — - // the raw rows still render verbatim when re-emitted. - assert!(parsed.entries[0].new_sha.is_empty()); - } - - #[test] - fn marker_line_round_trips_through_entries_from_marker() { - // The note and the comment share one marker schema. A comment's - // marker line must decode back into the same entries so the - // note-based read path reproduces history exactly. - let mut comment = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ChangeType::Content, - t(), - "first reason", - None, - ); - comment.append( - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "cccccccccccccccccccccccccccccccccccccccc", - ChangeType::Rebase, - t(), - "", - Some("dddddddddddddddddddddddddddddddddddddddd"), - ); - - let line = comment.marker_line(42); - let payload = parse_marker_line(&line).expect("marker line parses"); - let entries = entries_from_marker(&payload).expect("entries decode"); - assert_eq!(entries, comment.entries); - } - - #[test] - fn marker_line_survives_a_reason_quoting_an_html_comment() { - // A reason may quote `<!-- x -->` verbatim. Left as-is it - // closes the marker's own HTML comment, and a renderer - // spills the remaining JSON into the PR comment as text. - let reason = "guard reads the stripped body, so `<!-- x -->## Commit Message` cannot slip"; - let mut comment = RevisionHistoryComment::create_initial( - "https://api.github.com", - "o", - "r", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ChangeType::Content, - t(), - reason, - None, - ); - comment.append( - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "cccccccccccccccccccccccccccccccccccccccc", - ChangeType::Rebase, - t(), - "after the quoting revision", - None, - ); - - let line = comment.marker_line(42); - // The only delimiters left are the marker's own. - assert_eq!(line.matches("-->").count(), 1); - assert!(line.ends_with(MARKER_SUFFIX)); - assert_eq!(line.matches("<!--").count(), 1); - assert!(line.starts_with(MARKER_PREFIX)); - // And the escape is transparent to the read path. - let payload = parse_marker_line(&line).expect("marker line parses"); - let entries = entries_from_marker(&payload).expect("entries decode"); - assert_eq!(entries, comment.entries); - // `create_initial` puts the reason on the second (real) row. - assert_eq!(entries[1].reason, reason); - } - - #[test] - fn entries_from_marker_rejects_bad_schema_and_empty() { - // Wrong schema_version → None. - let wrong = serde_json::json!({"schema_version": 2, "pull_number": 1, "entries": []}); - assert!(entries_from_marker(&wrong).is_none()); - // Valid version but empty entries → None (nothing to build on). - let empty = serde_json::json!({"schema_version": 1, "pull_number": 1, "entries": []}); - assert!(entries_from_marker(&empty).is_none()); - // Entry missing new_sha → None (bail, don't fabricate). - let broken = serde_json::json!({ - "schema_version": 1, "pull_number": 1, - "entries": [{"number": 1, "change_type": "initial", "old_sha": null, - "timestamp_iso": null, "reason": "", "replay_sha": null}], - }); - assert!(entries_from_marker(&broken).is_none()); - // Entry with an empty new_sha → None (present-but-empty is - // as invalid as absent). - let empty_sha = serde_json::json!({ - "schema_version": 1, "pull_number": 1, - "entries": [{"number": 1, "change_type": "initial", "old_sha": null, - "new_sha": "", "timestamp_iso": null, "reason": "", - "replay_sha": null}], - }); - assert!(entries_from_marker(&empty_sha).is_none()); - } - - #[test] - fn from_entries_builds_comment_that_renders() { - let entries = vec![RevisionEntry { - number: 1, - change_type: ChangeType::Initial, - old_sha: None, - new_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), - timestamp: Some(t()), - reason: String::new(), - replay_sha: None, - }]; - let comment = - RevisionHistoryComment::from_entries("https://api.github.com", "o", "r", entries); - let body = comment.body(7); - assert!(body.starts_with("### Revision history\n")); - assert!(body.contains("| 1 | initial |")); - } - - #[test] - fn contains_marker_detects_marker_lines_only() { - assert!(contains_marker( - "digest text\n\n<!-- mergify-revision-data: {\"schema_version\":1} -->\n" - )); - assert!(!contains_marker("just a plain amend reason")); - } -} diff --git a/crates/mergify-stack/src/revision_note.rs b/crates/mergify-stack/src/revision_note.rs deleted file mode 100644 index 87ae526a..00000000 --- a/crates/mergify-stack/src/revision_note.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! The revision-history **git note** — the machine source of -//! truth for a stacked change's revision history, stored on -//! `refs/notes/mergify/stack` against the pushed head commit. -//! -//! Format: a human digest (newest revision first, no compare -//! URLs — they reference SHAs that get GC'd), a blank line, then -//! the same `<!-- mergify-revision-data: {…} -->` marker line the -//! PR comment carries (one schema, owned by -//! [`crate::revision_history`]). The PR comment is a rendering of -//! this data; nothing machine-reads the comment anymore. -//! -//! The note replaces the plain amend-reason note `mergify stack -//! note` wrote on the same commit — the reason is consumed into -//! the new revision entry at push time. Disambiguation rule: a -//! note containing a marker line is a history note; a note -//! without one is a plain reason. -//! -//! `git notes merge --strategy=union` (run by -//! [`crate::notes_push::fetch_notes_ref`]) can concatenate two -//! divergent notes, so [`parse`] scans *all* lines for markers -//! and keeps the payload with the most entries (tie → the later -//! one). The next push rewrites a clean note — self-healing. -//! -//! At merge time the Mergify engine copies this note's blob -//! verbatim onto the merge/squash commit, so the history stays -//! reachable from the base branch forever. The engine never -//! parses the content — the CLI owns the format outright. - -use std::fmt::Write as _; -use std::path::Path; -use std::process::Command; - -use mergify_core::{CliError, HttpClient}; -use serde::Deserialize; - -use crate::git::run_git_capture; -use crate::local_commits::STACK_NOTES_REF; -use crate::revision_history::{self, RevisionHistoryComment, TIMESTAMP_HUMAN_FMT}; - -/// Render the full note text for `history`: digest + marker. -#[must_use] -pub fn render(history: &RevisionHistoryComment, pull_number: u64) -> String { - let mut out = format!("Revision history for #{pull_number}:\n\n"); - for entry in history.entries.iter().rev() { - // Infallible: write! into a String cannot fail. - let _ = write!(out, "rev {} ({})", entry.number, entry.change_type.as_str()); - if let Some(ts) = &entry.timestamp { - let _ = write!(out, " {}", ts.format(TIMESTAMP_HUMAN_FMT)); - } - if !entry.reason.is_empty() { - let _ = write!(out, " \u{2014} {}", entry.reason.replace('\n', " ")); - } - out.push('\n'); - } - out.push('\n'); - out.push_str(&history.marker_line(pull_number)); - out.push('\n'); - out -} - -/// Parse a note back into a history. Returns `None` when no line -/// carries a valid marker — i.e. the note is a plain amend -/// reason (or absent history). -#[must_use] -pub fn parse( - note_text: &str, - github_server: &str, - user: &str, - repo: &str, -) -> Option<RevisionHistoryComment> { - let mut best: Option<Vec<revision_history::RevisionEntry>> = None; - for line in note_text.lines() { - let Some(payload) = revision_history::parse_marker_line(line) else { - continue; - }; - let Some(entries) = revision_history::entries_from_marker(&payload) else { - continue; - }; - // `>=` so the later of two equally-long histories wins. - if best.as_ref().is_none_or(|b| entries.len() >= b.len()) { - best = Some(entries); - } - } - best.map(|entries| RevisionHistoryComment::from_entries(github_server, user, repo, entries)) -} - -/// Recover the history a previous (failed) push attempt already -/// wrote on the local head commit. Returns it only when its last -/// entry records exactly the `old_sha` → `new_sha` transition the -/// current push is about to append — anything else (another -/// machine pushed meanwhile, a different amend) means the note is -/// stale and the caller must rebuild from the old head's note. -/// Keeping the recovered entry (reason, timestamp, and all) makes -/// retries idempotent instead of appending a blank-reason -/// duplicate. -#[must_use] -pub fn recover_pending( - note_text: &str, - github_server: &str, - user: &str, - repo: &str, - old_sha: &str, - new_sha: &str, -) -> Option<RevisionHistoryComment> { - parse(note_text, github_server, user, repo).filter(|h| { - h.entries - .last() - .is_some_and(|e| e.old_sha.as_deref() == Some(old_sha) && e.new_sha == new_sha) - }) -} - -/// Read the raw note text on `sha` from the stack notes ref. -/// `None` covers both "no note" and "git failed" — the caller -/// treats both as "no previous history here". -#[must_use] -pub fn read_note(repo_dir: Option<&Path>, sha: &str) -> Option<String> { - let notes_ref = format!("--ref={STACK_NOTES_REF}"); - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - let output = cmd.args(["notes", ¬es_ref, "show", sha]).output().ok()?; - if !output.status.success() { - return None; - } - Some(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -/// Overwrite the note on `sha` with `text` (`git notes add -f`). -pub fn write_note(repo_dir: Option<&Path>, sha: &str, text: &str) -> Result<(), CliError> { - let notes_ref = format!("--ref={STACK_NOTES_REF}"); - run_git_capture( - repo_dir, - &["notes", ¬es_ref, "add", "-f", "-m", text, sha], - ) - .map(|_| ()) -} - -#[derive(Deserialize)] -struct Comment { - body: String, -} - -/// Load the previous revision history for a change whose old -/// remote head is `old_sha`. -/// -/// Read order: -/// 1. History note on `old_sha` (present locally after -/// `fetch_notes_ref`) — the steady state. -/// 2. The PR's revision-history comment — one-time migration -/// seed for stacks last pushed by an older CLI. Converges on -/// first push: the caller writes the note, and this GET never -/// runs again for the change. -/// 3. `None` — first revision of this PR; caller starts fresh -/// with `create_initial`. -pub async fn load_or_seed( - client: &HttpClient, - repo_dir: Option<&Path>, - github_server: &str, - user: &str, - repo: &str, - pull_number: u64, - old_sha: &str, -) -> Result<Option<RevisionHistoryComment>, CliError> { - if let Some(text) = read_note(repo_dir, old_sha) - && let Some(history) = parse(&text, github_server, user, repo) - { - return Ok(Some(history)); - } - - let path = format!("/repos/{user}/{repo}/issues/{pull_number}/comments"); - let comments: Vec<Comment> = client.get(&path).await?; - for comment in &comments { - if RevisionHistoryComment::is_revision_comment(&comment.body) - && let Some(parsed) = - RevisionHistoryComment::parse(&comment.body, github_server, user, repo) - { - return Ok(Some(parsed)); - } - } - Ok(None) -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{TimeZone, Utc}; - use mergify_core::ApiFlavor; - use serde_json::json; - use tempfile::TempDir; - use url::Url; - use wiremock::matchers::{method, path as wm_path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - use crate::change_type::ChangeType; - - const GH: &str = "https://api.github.com"; - - fn t() -> chrono::DateTime<Utc> { - Utc.with_ymd_and_hms(2026, 6, 4, 12, 30, 0).unwrap() - } - - fn sample_history() -> RevisionHistoryComment { - let mut h = RevisionHistoryComment::create_initial( - GH, - "o", - "r", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ChangeType::Content, - t(), - "first reason", - None, - ); - h.append( - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "cccccccccccccccccccccccccccccccccccccccc", - ChangeType::Rebase, - t(), - "", - None, - ); - h - } - - #[test] - fn render_parse_round_trip() { - let history = sample_history(); - let note = render(&history, 42); - // Digest is newest-first, human-readable, no URLs. The - // marker line that follows the digest *does* carry - // `compare_url` fields (it's the exact same JSON schema - // the PR comment marker uses), so scope the "no compare - // URLs" check to the digest portion only. - assert!(note.starts_with("Revision history for #42:\n\n")); - assert!(note.contains("rev 3 (rebase)")); - assert!(note.contains("rev 1 (initial)")); - assert!(note.contains("\u{2014} first reason")); - let digest = note.split("<!-- mergify-revision-data:").next().unwrap(); - assert!(!digest.contains("compare")); - - let parsed = parse(¬e, GH, "o", "r").expect("round-trips"); - assert_eq!(parsed.entries, history.entries); - } - - #[test] - fn parse_plain_reason_returns_none() { - assert!(parse("fixed a typo in the docs", GH, "o", "r").is_none()); - } - - #[test] - fn parse_union_merged_note_keeps_longest_history() { - // Union merge concatenated a 2-entry history with a - // 3-entry one (order shouldn't matter): the 3-entry - // payload must win. - let long = sample_history(); // 3 entries - let short = RevisionHistoryComment::create_initial( - GH, - "o", - "r", - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - ChangeType::Content, - t(), - "first reason", - None, - ); // 2 entries - let concatenated = format!("{}\n{}", render(&long, 42), render(&short, 42)); - let parsed = parse(&concatenated, GH, "o", "r").expect("parses"); - assert_eq!(parsed.entries.len(), 3); - - let concatenated_rev = format!("{}\n{}", render(&short, 42), render(&long, 42)); - let parsed = parse(&concatenated_rev, GH, "o", "r").expect("parses"); - assert_eq!(parsed.entries.len(), 3); - } - - #[test] - fn parse_tolerates_garbage_around_marker() { - let note = format!( - "user scribble\n<!-- mergify-revision-data: not json -->\n{}", - render(&sample_history(), 42), - ); - let parsed = parse(¬e, GH, "o", "r").expect("valid marker still found"); - assert_eq!(parsed.entries.len(), 3); - } - - #[test] - fn recover_pending_matches_last_entry_transition() { - let history = sample_history(); - let note = render(&history, 42); - let last = history.entries.last().unwrap(); - let recovered = recover_pending( - ¬e, - GH, - "o", - "r", - last.old_sha.as_deref().unwrap(), - &last.new_sha, - ) - .expect("recovers matching pending history"); - assert_eq!(recovered.entries, history.entries); - } - - #[test] - fn recover_pending_rejects_mismatched_last_entry() { - let history = sample_history(); - let note = render(&history, 42); - let last = history.entries.last().unwrap(); - // A different new_sha than what's recorded means another - // push (or amend) happened since this note was written — - // the note is stale and must not be reused. - assert!( - recover_pending( - ¬e, - GH, - "o", - "r", - last.old_sha.as_deref().unwrap(), - "ffffffffffffffffffffffffffffffffffffffff", - ) - .is_none() - ); - } - - // --- git IO --- - - fn init_repo() -> TempDir { - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "t@e.com"], - &["config", "user.name", "T"], - &["commit", "--allow-empty", "-m", "root"], - ] { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir.path()) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?} failed"); - } - dir - } - - fn head_sha(dir: &std::path::Path) -> String { - run_git_capture(Some(dir), &["rev-parse", "HEAD"]).unwrap() - } - - #[test] - fn write_then_read_note_round_trips() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - let text = render(&sample_history(), 42); - write_note(Some(dir.path()), &sha, &text).unwrap(); - let read = read_note(Some(dir.path()), &sha).expect("note exists"); - // `git notes show` normalises the trailing newline; compare - // trimmed. - assert_eq!(read.trim_end(), text.trim_end()); - // And the full note→history read path works. - assert!(parse(&read, GH, "o", "r").is_some()); - } - - #[test] - fn write_note_overwrites_existing_plain_reason() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - write_note(Some(dir.path()), &sha, "plain reason").unwrap(); - let text = render(&sample_history(), 42); - write_note(Some(dir.path()), &sha, &text).unwrap(); - let read = read_note(Some(dir.path()), &sha).unwrap(); - assert!(read.contains("mergify-revision-data")); - assert!(!read.contains("plain reason")); - } - - #[test] - fn read_note_missing_returns_none() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - assert!(read_note(Some(dir.path()), &sha).is_none()); - } - - // --- load_or_seed --- - - fn client(server: &MockServer) -> HttpClient { - HttpClient::new( - Url::parse(&server.uri()).unwrap(), - "token", - ApiFlavor::GitHub, - ) - .unwrap() - } - - #[tokio::test] - async fn load_prefers_note_over_comment() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - write_note(Some(dir.path()), &sha, &render(&sample_history(), 42)).unwrap(); - - // No comment GET expected: mock with .expect(0). - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(0) - .mount(&server) - .await; - - let got = load_or_seed(&client(&server), Some(dir.path()), GH, "o", "r", 42, &sha) - .await - .unwrap() - .expect("note found"); - assert_eq!(got.entries.len(), 3); - } - - #[tokio::test] - async fn load_seeds_from_comment_when_note_missing() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - let comment_body = sample_history().body(42); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"url": format!("{}/repos/o/r/issues/comments/1", server.uri()), "body": comment_body}, - ]))) - .expect(1) - .mount(&server) - .await; - - let got = load_or_seed(&client(&server), Some(dir.path()), GH, "o", "r", 42, &sha) - .await - .unwrap() - .expect("seeded from comment"); - assert_eq!(got.entries.len(), 3); - } - - #[tokio::test] - async fn load_returns_none_when_neither_exists() { - let dir = init_repo(); - let sha = head_sha(dir.path()); - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let got = load_or_seed(&client(&server), Some(dir.path()), GH, "o", "r", 42, &sha) - .await - .unwrap(); - assert!(got.is_none()); - } - - #[tokio::test] - async fn load_ignores_plain_reason_note_and_falls_through() { - // Old head carries a plain amend reason (older-CLI state): - // not a history note → fall through to the comment seed. - let dir = init_repo(); - let sha = head_sha(dir.path()); - write_note(Some(dir.path()), &sha, "an old plain reason").unwrap(); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(wm_path("/repos/o/r/issues/42/comments")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let got = load_or_seed(&client(&server), Some(dir.path()), GH, "o", "r", 42, &sha) - .await - .unwrap(); - assert!(got.is_none()); - } -} diff --git a/crates/mergify-stack/src/slug.rs b/crates/mergify-stack/src/slug.rs deleted file mode 100644 index c4cb1806..00000000 --- a/crates/mergify-stack/src/slug.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Branch-name slug derivation from a commit title + Change-Id. -//! -//! Mirrors `mergify_cli/stack/slug.py::slugify_title`. The output -//! shape is `<slug>--<hex8>` where: -//! -//! - `<slug>` is the title with the conventional-commit prefix -//! stripped, stop-words removed, common nouns abbreviated, then -//! lower-cased + ASCII-only + hyphenated, capped at 50 chars on -//! a word boundary. -//! - `<hex8>` is the first 8 hex chars of the Change-Id (with the -//! leading `I` stripped). -//! -//! Slug stability across commit-message rewrites matters: as long -//! as the Change-Id is preserved an amend keeps the same branch -//! name and the existing PR is reused. The truncation + fallback -//! exist so a degenerate title (e.g. `"feat: "`, all stop words) -//! still yields a usable branch name. - -use std::sync::LazyLock; - -use regex::Regex; - -const MAX_SLUG_LENGTH: usize = 50; -const SHORT_HASH_LENGTH: usize = 8; - -/// `type(scope)!:` conventional-commit prefix — case-insensitive. -static CONVENTIONAL_COMMIT_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"(?i)^[a-z]+(?:\([^)]*\))?[!]?:\s*").unwrap()); - -/// Split a sub-token boundary at any non-ASCII-alphanumeric run. -/// Matches Python's `re.split(r"[^a-zA-Z0-9]+", token)` semantics: -/// the regex crate's default is Unicode-aware, so the `[^…]` -/// complement also includes accented letters — which is what we -/// want here (`modèle` → `["mod", "le"]`). -static NON_ALPHANUMERIC_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9]+").unwrap()); - -/// Final pass to coalesce any leftover non-`[a-z0-9-]` chars into -/// hyphens after the slug joins. Belt-and-braces with -/// [`NON_ALPHANUMERIC_RE`] since the joining step adds `-` between -/// tokens and we want to dedupe consecutive `-`. -static NON_SLUG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-z0-9-]").unwrap()); - -static MULTIPLE_HYPHEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-{2,}").unwrap()); - -/// Canonical singular/plural pairs the slug shortens. Mirrors the -/// Python table verbatim so old branches stay reachable when the -/// CLI is upgraded — changing this map would silently fork branch -/// names on existing stacks. -const ABBREVIATIONS: &[(&str, &str)] = &[ - ("application", "app"), - ("applications", "apps"), - ("authentification", "auth"), - ("authentication", "auth"), - ("authorization", "authz"), - ("command", "cmd"), - ("commands", "cmds"), - ("configuration", "config"), - ("connection", "conn"), - ("connections", "conns"), - ("dependency", "dep"), - ("dependencies", "deps"), - ("description", "desc"), - ("development", "dev"), - ("directory", "dir"), - ("documentation", "docs"), - ("environment", "env"), - ("environments", "envs"), - ("function", "func"), - ("functions", "funcs"), - ("generation", "gen"), - ("implement", "impl"), - ("implementation", "impl"), - ("information", "info"), - ("initialization", "init"), - ("library", "lib"), - ("libraries", "libs"), - ("management", "mgmt"), - ("message", "msg"), - ("messages", "msgs"), - ("middleware", "mw"), - ("notification", "notif"), - ("notifications", "notifs"), - ("number", "num"), - ("package", "pkg"), - ("packages", "pkgs"), - ("parameter", "param"), - ("parameters", "params"), - ("performance", "perf"), - ("production", "prod"), - ("property", "prop"), - ("properties", "props"), - ("reference", "ref"), - ("references", "refs"), - ("repository", "repo"), - ("repositories", "repos"), - ("request", "req"), - ("requests", "reqs"), - ("response", "resp"), - ("responses", "resps"), - ("specification", "spec"), - ("specifications", "specs"), - ("statistics", "stats"), - ("subscription", "sub"), - ("subscriptions", "subs"), - ("synchronization", "sync"), - ("temporary", "tmp"), - ("transaction", "tx"), - ("transactions", "txs"), - ("utilities", "utils"), - ("utility", "util"), - ("validation", "val"), - ("variable", "var"), - ("variables", "vars"), -]; - -const STOP_WORDS: &[&str] = &[ - "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", - "from", "is", "are", "this", "that", "it", "its", "into", "as", "so", "be", "was", "were", - "not", "no", "has", "have", "had", "will", "would", "can", "could", "should", "do", "does", - "did", "just", "also", "when", "where", "how", "if", "then", "than", "more", "some", "all", - "each", "every", "any", "both", "about", "between", "through", "during", "before", "after", - "up", "out", "new", -]; - -fn abbreviate(token: &str) -> &str { - for (full, abbr) in ABBREVIATIONS { - if *full == token { - return abbr; - } - } - token -} - -fn is_stop_word(token: &str) -> bool { - STOP_WORDS.contains(&token) -} - -/// Build the deterministic branch slug for `title` + `change_id`. -/// -/// `change_id` is expected to be a full Change-Id (`I` + 40 hex); -/// only the first 8 hex chars after the `I` are used. Anything -/// shorter is taken as-is and may produce a slug suffix shorter -/// than 8 chars — the caller should validate the Change-Id shape -/// before calling. -#[must_use] -pub fn slugify_title(title: &str, change_id: &str) -> String { - // 1. Strip conventional commit prefix (`feat:`, `fix(scope):`, …). - let after_prefix = CONVENTIONAL_COMMIT_RE.replace(title, ""); - - // 2. Tokenise. Whitespace splits first (preserving Python's - // `text.split()` semantics) then each token is split again - // on every non-ASCII-alphanumeric run. - let mut words: Vec<String> = Vec::new(); - for ws_token in after_prefix.split_whitespace() { - for sub in NON_ALPHANUMERIC_RE.split(ws_token) { - if sub.is_empty() { - continue; - } - let lower = sub.to_lowercase(); - let abbreviated = abbreviate(&lower); - if !abbreviated.is_empty() && !is_stop_word(abbreviated) { - words.push(abbreviated.to_string()); - } - } - } - - // 3. Join + normalise. The join is `-`; any stray non-slug char - // that survived (shouldn't happen — tokens are alphanumeric - // by construction) gets collapsed to `-`, then consecutive - // hyphens are deduplicated and edges trimmed. - let mut slug = words.join("-"); - slug = NON_SLUG_RE.replace_all(&slug, "-").into_owned(); - slug = MULTIPLE_HYPHEN_RE.replace_all(&slug, "-").into_owned(); - slug = slug.trim_matches('-').to_string(); - - // 4. Truncate at the last word boundary inside the cap so the - // slug never ends mid-word. - if slug.len() > MAX_SLUG_LENGTH { - let truncated = &slug[..MAX_SLUG_LENGTH]; - slug = match truncated.rfind('-') { - Some(pos) if pos > 0 => truncated[..pos].to_string(), - _ => truncated.to_string(), - }; - } - slug = slug.trim_matches('-').to_string(); - - // 5. Fallback for degenerate inputs (all stop words, empty - // title after prefix strip, etc.). - if slug.is_empty() { - slug = "change".to_string(); - } - - // 6. Append the short Change-Id (strip leading `I`, take first - // 8 hex chars). - let hex_suffix: String = change_id - .strip_prefix('I') - .unwrap_or(change_id) - .chars() - .take(SHORT_HASH_LENGTH) - .collect(); - - format!("{slug}--{hex_suffix}") -} - -#[cfg(test)] -mod tests { - use super::*; - - const CID: &str = "I29617d37762fd69809c255d7e7073cb11f8fbf50"; - - #[test] - fn basic_slugification() { - assert_eq!( - slugify_title("Add user model", CID), - "add-user-model--29617d37" - ); - } - - #[test] - fn conventional_commit_prefix_is_stripped() { - assert_eq!( - slugify_title("feat(stack): improve comment design", CID), - "improve-comment-design--29617d37", - ); - assert_eq!( - slugify_title("fix: handle missing change id", CID), - "handle-missing-change-id--29617d37", - ); - } - - #[test] - fn stop_words_are_removed() { - // "the", "in" → dropped. - assert_eq!( - slugify_title("Fix the bug in the parser", CID), - "fix-bug-parser--29617d37", - ); - } - - #[test] - fn abbreviations_are_applied() { - assert_eq!( - slugify_title("Add user authentication model", CID), - "add-user-auth-model--29617d37", - ); - assert_eq!( - slugify_title("Implement repository synchronization", CID), - "impl-repo-sync--29617d37", - ); - } - - #[test] - fn non_ascii_chars_split_tokens() { - // `l'authentification` → `l` + `authentification` (→ `auth`). - // `modèle` → `mod` + `le` (the `è` is non-ASCII so it splits). - assert_eq!( - slugify_title("Fix l'authentification du modèle", CID), - "fix-l-auth-du-mod-le--29617d37", - ); - } - - #[test] - fn special_chars_split_tokens() { - assert_eq!( - slugify_title("Add foo_bar & baz.qux", CID), - "add-foo-bar-baz-qux--29617d37", - ); - } - - #[test] - fn short_title_passes_through() { - assert_eq!(slugify_title("fix typo", CID), "fix-typo--29617d37"); - } - - #[test] - fn all_stop_words_falls_back_to_change() { - // Every token is a stop word → fallback to `"change"` so - // the branch name is still well-formed. - assert_eq!(slugify_title("It is the", CID), "change--29617d37"); - } - - #[test] - fn empty_after_prefix_strip_falls_back_to_change() { - // `"feat: "` → `""` after prefix strip → fallback. - assert_eq!(slugify_title("feat: ", CID), "change--29617d37"); - } - - #[test] - fn long_title_truncates_at_word_boundary() { - // 20× "word " → joined slug is way over 50 chars; truncate - // at the last `-` inside the cap so we don't end mid-word. - let long_title = "word ".repeat(20); - let result = slugify_title(&long_title, CID); - let slug_part = result.rsplit_once("--").unwrap().0; - assert!(slug_part.len() <= MAX_SLUG_LENGTH); - assert!(!slug_part.ends_with('-')); - assert!(result.ends_with("--29617d37")); - } - - #[test] - fn different_change_ids_produce_different_suffixes() { - let title = "Add feature"; - assert_eq!( - slugify_title(title, "Iaaaaaaa0762fd69809c255d7e7073cb11f8fbf50"), - "add-feature--aaaaaaa0", - ); - assert_eq!( - slugify_title(title, "Ibbbbbbbb762fd69809c255d7e7073cb11f8fbf50"), - "add-feature--bbbbbbbb", - ); - } -} diff --git a/crates/mergify-stack/src/stack_comment.rs b/crates/mergify-stack/src/stack_comment.rs deleted file mode 100644 index 071b27c4..00000000 --- a/crates/mergify-stack/src/stack_comment.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! The "this PR is part of a stack" sticky comment, posted on -//! every PR of a stack GitHub is **not** describing itself. -//! -//! Since GitHub's native Stacks UI renders the member list on the -//! pull request page, a registered stack gets no comment from us — -//! [`crate::commands::push`] posts this only when the registration -//! did not happen (`--no-github-native`, a GitHub that does not -//! have the feature, a chain GitHub would not accept), and takes -//! down any comment it finds when it did. So this module renders -//! the fallback surface, not the default one. -//! -//! Body has three parts: -//! -//! 1. A markdown header pointing at the docs. -//! 2. A 3-column table — `# | Pull Request | Link | 👈` — -//! listing every PR in the stack, with the row of the PR being -//! rendered carrying a 👈 emoji. -//! 3. A single-line HTML comment with the marker prefix -//! `<!-- mergify-stack-data: {…} -->` carrying the same data -//! as JSON. -//! -//! Nothing in this CLI reads that marker (checked 2026-09-21). -//! An earlier version of this docstring claimed `mergify stack -//! checkout` rebuilt a stack from it; it does not, and there is no -//! sign it ever did — [`crate::commands::checkout`] discovers a -//! stack by chaining each PR's `head.ref` to the next one's -//! `base.ref`, which has the advantage of working on stacks this -//! CLI never touched. What the marker is for is readers outside -//! this repo, which is why its wire shape is still pinned below. -//! -//! Ported from `mergify_cli/stack/push.py::StackComment`. Wire -//! shape — header strings, JSON payload, compact one-line marker -//! — is contract: existing comments on every Mergify-managed PR -//! need to parse, and the header is how the upserter and the -//! remover recognise one of ours. - -use std::fmt::Write; - -use serde::Serialize; - -/// Markdown header for the current generation of the comment. -/// Match against this to identify a fresh sticky comment posted by -/// the current CLI. -pub const STACK_COMMENT_HEADER: &str = - "This pull request is part of a [Mergify stack](https://docs.mergify.com/stacks/):\n"; - -/// Legacy header used by older CLI versions. Kept in -/// [`is_stack_comment`] so an in-place rewrite of an old comment -/// still recognises it as ours instead of leaving a duplicate. -pub const STACK_COMMENT_OLD_HEADER: &str = "This pull request is part of a stack:\n"; - -const MARKER_PREFIX: &str = "<!-- mergify-stack-data: "; -const MARKER_SUFFIX: &str = " -->"; - -/// One row in the stack comment + one entry in the JSON marker. -/// Caller materialises this from a `LocalChange` once the PR -/// number and head SHA are known (i.e. after `stack push` has -/// upserted the PR). -#[derive(Debug, Clone)] -pub struct StackEntry { - pub number: u64, - pub change_id: String, - pub head_sha: String, - pub base_branch: String, - pub dest_branch: String, - pub title: String, - pub html_url: String, -} - -/// Render the comment body for the PR identified by -/// `current_number`. `stack_id` goes into the JSON marker only. -#[must_use] -pub fn body(entries: &[StackEntry], current_number: u64, stack_id: &str) -> String { - let mut out = String::with_capacity(256 + entries.len() * 64); - out.push_str(STACK_COMMENT_HEADER); - out.push('\n'); - out.push_str("| # | Pull Request | Link | |\n"); - out.push_str("|--:|---|---|---|\n"); - - for (row, entry) in entries.iter().enumerate() { - let row_num = row + 1; - // GitHub markdown table cells: `|` is the column - // separator, so the title's literal pipes must escape. - let title = entry.title.replace('|', "\\|"); - let status = if entry.number == current_number { - "👈" - } else { - "" - }; - writeln!( - out, - "| {row_num} | {title} | [#{number}]({url}) | {status} |", - number = entry.number, - url = entry.html_url, - ) - .expect("write to String never fails"); - } - - out.push_str(&json_marker(entries, current_number, stack_id)); - out.push('\n'); - out -} - -/// True if `comment_body` starts with either the current or the -/// legacy stack-comment header. Used by the upserter to decide -/// "edit this comment" vs "post a new one." -#[must_use] -pub fn is_stack_comment(comment_body: &str) -> bool { - comment_body.starts_with(STACK_COMMENT_HEADER) - || comment_body.starts_with(STACK_COMMENT_OLD_HEADER) -} - -#[derive(Serialize)] -struct MarkerPull<'a> { - number: u64, - change_id: &'a str, - head_sha: &'a str, - base_branch: &'a str, - dest_branch: &'a str, - is_current: bool, -} - -#[derive(Serialize)] -struct MarkerPayload<'a> { - schema_version: u8, - stack_id: &'a str, - pulls: Vec<MarkerPull<'a>>, -} - -fn json_marker(entries: &[StackEntry], current_number: u64, stack_id: &str) -> String { - // `is_current` is a JSON boolean — Python emits the result of - // `int(...) == current_number` directly, which `json.dumps` - // serialises as `true`/`false`. Out-of-tree readers (the - // browser extension) expect a bool; an integer would break - // historic-comment parsing. - let pulls = entries - .iter() - .map(|e| MarkerPull { - number: e.number, - change_id: &e.change_id, - head_sha: &e.head_sha, - base_branch: &e.base_branch, - dest_branch: &e.dest_branch, - is_current: e.number == current_number, - }) - .collect(); - let payload = MarkerPayload { - schema_version: 1, - stack_id, - pulls, - }; - // serde_json's default formatter is already compact (no - // whitespace), matching Python's `separators=(",", ":")`. - let json = serde_json::to_string(&payload).expect("MarkerPayload always serialises"); - format!("{MARKER_PREFIX}{json}{MARKER_SUFFIX}") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(number: u64, change_id: &str, head_sha: &str) -> StackEntry { - StackEntry { - number, - change_id: change_id.to_string(), - head_sha: head_sha.to_string(), - base_branch: "main".to_string(), - dest_branch: format!("jd/feature/{}", &change_id[..7]), - title: format!("feat: change {number}"), - html_url: format!("https://github.com/o/r/pull/{number}"), - } - } - - #[test] - fn header_marks_current_row_with_pointer() { - let entries = vec![ - entry( - 1, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "1111111111111111111111111111111111111111", - ), - entry( - 2, - "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "2222222222222222222222222222222222222222", - ), - ]; - let out = body(&entries, 2, "feature"); - // The 👈 emoji must land on the row whose `number` equals - // `current_number` — readers rely on it to find "you are - // here" in a long stack. - assert!(out.contains("| 2 | feat: change 2 | [#2](https://github.com/o/r/pull/2) | 👈 |")); - assert!(out.contains("| 1 | feat: change 1 | [#1](https://github.com/o/r/pull/1) | |")); - } - - #[test] - fn table_cells_escape_literal_pipes_in_title() { - // A pipe in the title would otherwise close the column - // and corrupt the GitHub-rendered table. - let mut e = entry( - 1, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "1111111111111111111111111111111111111111", - ); - e.title = "feat: support a|b syntax".into(); - let out = body(&[e], 1, "feature"); - assert!(out.contains("feat: support a\\|b syntax")); - } - - #[test] - fn json_marker_round_trips_payload_with_is_current_flag() { - let entries = vec![ - entry( - 1, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "1111111111111111111111111111111111111111", - ), - entry( - 2, - "Ibbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "2222222222222222222222222222222222222222", - ), - ]; - let out = body(&entries, 1, "feature"); - - let marker_line = out - .lines() - .find(|l| l.starts_with(MARKER_PREFIX)) - .expect("marker line present"); - assert!(marker_line.ends_with(MARKER_SUFFIX)); - - let payload = &marker_line[MARKER_PREFIX.len()..marker_line.len() - MARKER_SUFFIX.len()]; - let parsed: serde_json::Value = serde_json::from_str(payload).unwrap(); - assert_eq!(parsed["schema_version"], 1); - assert_eq!(parsed["stack_id"], "feature"); - assert_eq!(parsed["pulls"][0]["number"], 1); - assert_eq!(parsed["pulls"][0]["is_current"], true); - assert_eq!(parsed["pulls"][1]["number"], 2); - assert_eq!(parsed["pulls"][1]["is_current"], false); - // change_id / head_sha / base_branch / dest_branch carry - // through verbatim — out-of-tree readers index on them. - assert_eq!( - parsed["pulls"][0]["change_id"], - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ); - assert_eq!( - parsed["pulls"][1]["head_sha"], - "2222222222222222222222222222222222222222", - ); - } - - #[test] - fn json_marker_is_a_single_compact_line() { - // Same invariant Python pins: `separators=(",", ":")` — - // no whitespace between keys/values, no line breaks - // inside the marker. A multi-line marker would break the - // single-line readers in `_update_comment_for_pull`. - let e = entry( - 1, - "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "1111111111111111111111111111111111111111", - ); - let out = body(&[e], 1, "feature"); - let marker_line = out.lines().find(|l| l.starts_with(MARKER_PREFIX)).unwrap(); - assert!(!marker_line.contains("\", ")); - assert!(!marker_line.contains("\": ")); - } - - #[test] - fn is_stack_comment_accepts_current_and_legacy_headers() { - assert!(is_stack_comment(&format!("{STACK_COMMENT_HEADER}rest"))); - // Pre-docs-link generation; still ours. - assert!(is_stack_comment(&format!("{STACK_COMMENT_OLD_HEADER}rest"))); - assert!(!is_stack_comment("Some other comment\n")); - assert!(!is_stack_comment("")); - } -} diff --git a/crates/mergify-stack/src/stack_context.rs b/crates/mergify-stack/src/stack_context.rs deleted file mode 100644 index 08415d29..00000000 --- a/crates/mergify-stack/src/stack_context.rs +++ /dev/null @@ -1,449 +0,0 @@ -//! Stack-context resolution helpers. -//! -//! Shared by the GitHub-API-backed stack subcommands (`checkout`, -//! `sync`, `list`, `open`, `push`) — port of the per-command -//! preamble that Python's `mergify_cli/stack/cli.py` runs inside -//! the click group: -//! -//! - `github_server` from the `mergify-cli.github-server` git -//! config key, falling back to `https://api.github.com/`. -//! - Repository `(owner, repo)` from either an explicit -//! `--repository OWNER/REPO` argument or -//! `git config remote.<remote>.url` parsed by [`parse_slug`]. -//! - Default branch prefix from -//! `mergify-cli.stack-branch-prefix`, falling back to -//! `stack/<author>`. -//! - URL slug parser handling both HTTPS (`https://github.com/o/r`) -//! and SSH (`git@github.com:o/r.git`) shapes. - -use std::path::Path; - -use crate::git::run_git_capture; -use std::sync::LazyLock; - -use mergify_core::CliError; -use regex::Regex; -use url::Url; - -/// `--<8 hex>` suffix that `stack push` appends to auto-generated -/// branch names. Mirrors `_SLUG_SUFFIX_RE` in -/// `mergify_cli/stack/push.py`. -static SLUG_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"--[0-9a-f]{8}$").unwrap()); - -/// Legacy `/I<40 hex>` Change-Id suffix on stack branches. -static CHANGEID_BRANCH_SUFFIX_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"/I[0-9a-z]{40}$").unwrap()); - -/// Reject local branches that look auto-generated by -/// `mergify stack push` — running stack-modifying commands on a -/// generated leaf-branch instead of the parent stack branch is a -/// data-loss footgun. Mirrors -/// `mergify_cli/stack/push.py::check_local_branch`. -pub fn check_local_branch(branch_name: &str, branch_prefix: &str) -> Result<(), CliError> { - if !branch_name.starts_with(branch_prefix) { - return Ok(()); - } - if CHANGEID_BRANCH_SUFFIX_RE.is_match(branch_name) || SLUG_SUFFIX_RE.is_match(branch_name) { - return Err(CliError::InvalidState(format!( - "Local branch `{branch_name}` is a branch generated by Mergify CLI. \ - Switch to the parent stack branch before running this command." - ))); - } - Ok(()) -} - -/// Build the "your local branch targets itself" error every stack -/// command that resolves a trunk raises when the checked-out branch -/// *is* the trunk it would land on. -/// -/// Mirrors `sync.py` / `list.py`: a first line carrying the resolved -/// remote URL (`git remote get-url <remote>`, omitted gracefully -/// when that fails) followed by the two copy-pasteable git commands. -/// Returns [`CliError::InvalidState`] (exit 7), matching Python. -#[must_use] -pub fn targets_itself_error( - repo_dir: Option<&Path>, - dest_branch: &str, - remote: &str, - base_branch: &str, -) -> CliError { - let location = run_git_capture(repo_dir, &["remote", "get-url", remote]) - .ok() - .filter(|url| !url.is_empty()) - .map(|url| format!(" (at {url}@{base_branch})")) - .unwrap_or_default(); - CliError::InvalidState(format!( - "your local branch `{dest_branch}` targets itself: \ - `{remote}/{base_branch}`{location}\n\ - * To fix the target branch: \ - `git branch {dest_branch} --set-upstream-to={remote}/{base_branch}`\n\ - * To rename your local branch: \ - `git branch -M {dest_branch} new-branch-name`" - )) -} - -/// Owner + repository name pair, e.g. `("Mergifyio", "mergify-cli")`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RepoSlug { - pub owner: String, - pub repo: String, -} - -/// Parse a remote URL into `(owner, repo)`. Handles both HTTPS -/// (`https://github.com/owner/repo.git`) and SSH -/// (`git@github.com:owner/repo.git`) shapes. The trailing `.git` -/// suffix is stripped; a trailing `/` on the path is tolerated. -/// -/// Mirrors `mergify_cli/utils.py::get_slug`. -pub fn parse_slug(url: &str) -> Result<RepoSlug, CliError> { - // SSH shape: `user@host:owner/repo[.git]` — no `//` and the - // path lives after the first `:`. Python's `urlparse` returns - // empty netloc for this shape; we mirror by checking for `://`. - let path = if url.contains("://") { - // Validate via the `url` crate so we surface a clear error - // on garbage input. - let parsed = Url::parse(url).map_err(|e| { - CliError::InvalidState(format!("could not parse remote URL '{url}': {e}")) - })?; - parsed - .path() - .trim_start_matches('/') - .trim_end_matches('/') - .to_string() - } else { - let (_user_host, path) = url.split_once(':').ok_or_else(|| { - CliError::InvalidState(format!( - "remote URL '{url}' is not parseable as HTTPS or SSH" - )) - })?; - path.trim_end_matches('/').to_string() - }; - let (owner, repo) = path.split_once('/').ok_or_else(|| { - CliError::InvalidState(format!("remote URL '{url}' has no `<owner>/<repo>` path")) - })?; - let repo = repo.strip_suffix(".git").unwrap_or(repo); - if owner.is_empty() || repo.is_empty() { - return Err(CliError::InvalidState(format!( - "remote URL '{url}' parses to an empty owner or repo" - ))); - } - Ok(RepoSlug { - owner: owner.to_string(), - repo: repo.to_string(), - }) -} - -/// Resolve the repository from an explicit `--repository OWNER/REPO` -/// flag (preferred) or, when missing, by reading -/// `remote.<remote>.url` from git config and slug-parsing it. -pub fn resolve_repo( - repo_dir: Option<&Path>, - explicit_repository: Option<&str>, - remote: &str, -) -> Result<RepoSlug, CliError> { - if let Some(value) = explicit_repository { - let (owner, repo) = value.split_once('/').ok_or_else(|| { - CliError::InvalidState("--repository must be in the format 'owner/repo'".to_string()) - })?; - if owner.is_empty() || repo.is_empty() { - return Err(CliError::InvalidState( - "--repository must be in the format 'owner/repo'".to_string(), - )); - } - return Ok(RepoSlug { - owner: owner.to_string(), - repo: repo.to_string(), - }); - } - let key = format!("remote.{remote}.url"); - let url = run_git_capture(repo_dir, &["config", "--get", &key])?; - parse_slug(&url) -} - -/// Resolve `mergify-cli.github-server` from git config, falling -/// back to `https://api.github.com/`. The Python implementation -/// rewrites the scheme to `https` and tacks on `/api/v3` when the -/// host is not `api.github.com` (so GitHub Enterprise users can -/// configure just the host). -/// -/// The `MERGIFY_GITHUB_SERVER` env var overrides everything and -/// is taken verbatim — production users go through git config so -/// the https coercion still applies to them, but tests and ad- -/// hoc CI setups can point at any URL (including plain `http://` -/// for local wiremock servers) without the coercion getting in -/// the way. -pub fn resolve_github_server(repo_dir: Option<&Path>) -> Result<Url, CliError> { - if let Some(raw) = mergify_core::env::var_non_empty("MERGIFY_GITHUB_SERVER") { - return Url::parse(&raw).map_err(|e| { - CliError::InvalidState(format!("invalid MERGIFY_GITHUB_SERVER '{raw}': {e}")) - }); - } - let configured = run_git_capture(repo_dir, &["config", "--get", "mergify-cli.github-server"]) - .unwrap_or_default(); - let raw = if configured.is_empty() { - "https://api.github.com/".to_string() - } else { - configured - }; - let mut url = Url::parse(&raw).map_err(|e| { - CliError::InvalidState(format!("invalid mergify-cli.github-server '{raw}': {e}")) - })?; - // Force scheme to https — Python's `_replace(scheme="https")`. - if url.scheme() != "https" { - url.set_scheme("https") - .map_err(|()| CliError::InvalidState(format!("could not coerce '{raw}' to https")))?; - } - let host = url.host_str().unwrap_or("").to_string(); - if host == "api.github.com" { - url.set_path(""); - } else { - url.set_path("/api/v3"); - } - Ok(url) -} - -/// The explicitly configured `mergify-cli.stack-branch-prefix`, -/// or `None` when the repo leaves it to the default. -/// -/// Split out from [`resolve_default_branch_prefix`] for the caller -/// that has no author to fall back on and would rather pay for a -/// `GET /user` only when the config is silent. -#[must_use] -pub fn configured_branch_prefix(repo_dir: Option<&Path>) -> Option<String> { - let configured = run_git_capture( - repo_dir, - &["config", "--get", "mergify-cli.stack-branch-prefix"], - ) - .unwrap_or_default(); - (!configured.is_empty()).then_some(configured) -} - -/// The branch prefix used when nothing is configured. -#[must_use] -pub fn default_branch_prefix(author: &str) -> String { - format!("stack/{author}") -} - -/// Resolve the default branch prefix from -/// `mergify-cli.stack-branch-prefix`, falling back to -/// `stack/<author>`. Mirrors `utils.get_default_branch_prefix`. -#[must_use] -pub fn resolve_default_branch_prefix(repo_dir: Option<&Path>, author: &str) -> String { - configured_branch_prefix(repo_dir).unwrap_or_else(|| default_branch_prefix(author)) -} - -/// `mergify-cli.stack-create-as-draft`, defaulting to `false`. -/// Mirrors `utils.get_default_create_as_draft` — only treats the -/// literal string `"true"` as true so a value like `1` won't -/// silently flip the default. -#[must_use] -pub fn resolve_default_create_as_draft(repo_dir: Option<&Path>) -> bool { - run_git_capture( - repo_dir, - &["config", "--get", "mergify-cli.stack-create-as-draft"], - ) - .is_ok_and(|v| v == "true") -} - -/// `mergify-cli.stack-keep-pr-title-body`, defaulting to `false`. -/// Mirrors `utils.get_default_keep_pr_title_body`. -#[must_use] -pub fn resolve_default_keep_pr_title_body(repo_dir: Option<&Path>) -> bool { - run_git_capture( - repo_dir, - &["config", "--get", "mergify-cli.stack-keep-pr-title-body"], - ) - .is_ok_and(|v| v == "true") -} - -/// `mergify-cli.stack-revision-history`, defaulting to `true`. -/// Mirrors `utils.get_default_revision_history` — opt-out: only -/// the literal string `"false"` disables the feature. -#[must_use] -pub fn resolve_default_revision_history(repo_dir: Option<&Path>) -> bool { - run_git_capture( - repo_dir, - &["config", "--get", "mergify-cli.stack-revision-history"], - ) - .map_or(true, |v| v != "false") -} - -/// `mergify-cli.stack-github-native`, defaulting to `true`. -/// -/// Opt-out like `stack-revision-history`: only the literal string -/// `"false"` disables it, so a stray value can't silently turn off -/// registering stacks with GitHub — which changes how those pull -/// requests can be merged (see [`crate::native_stack`]). -#[must_use] -pub fn resolve_default_github_native(repo_dir: Option<&Path>) -> bool { - run_git_capture( - repo_dir, - &["config", "--get", "mergify-cli.stack-github-native"], - ) - .map_or(true, |v| v != "false") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_https_url() { - let slug = parse_slug("https://github.com/owner/repo.git").unwrap(); - assert_eq!(slug.owner, "owner"); - assert_eq!(slug.repo, "repo"); - } - - #[test] - fn targets_itself_includes_remote_url_and_both_git_commands() { - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &[ - "remote", - "add", - "origin", - "https://github.com/owner/repo.git", - ], - ] { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir.path()) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?} failed"); - } - let err = targets_itself_error(Some(dir.path()), "main", "origin", "main"); - match err { - CliError::InvalidState(msg) => { - assert!( - msg.contains( - "your local branch `main` targets itself: `origin/main` \ - (at https://github.com/owner/repo.git@main)" - ), - "got: {msg}" - ); - assert!( - msg.contains( - "* To fix the target branch: \ - `git branch main --set-upstream-to=origin/main`" - ), - "got: {msg}" - ); - assert!( - msg.contains( - "* To rename your local branch: \ - `git branch -M main new-branch-name`" - ), - "got: {msg}" - ); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn targets_itself_omits_location_when_remote_url_unresolvable() { - let dir = tempfile::tempdir().unwrap(); - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir.path()) - .args(["init", "-q", "-b", "main"]) - .status() - .unwrap() - .success(); - assert!(ok); - // No remote configured → `git remote get-url` fails; the - // `(at ...)` parenthetical is dropped without erroring. - let err = targets_itself_error(Some(dir.path()), "main", "origin", "main"); - match err { - CliError::InvalidState(msg) => { - assert!( - msg.contains("targets itself: `origin/main`\n"), - "location parenthetical should be absent: {msg}" - ); - assert!(!msg.contains("(at "), "got: {msg}"); - assert!(msg.contains("--set-upstream-to=origin/main"), "got: {msg}"); - } - other => panic!("expected InvalidState, got: {other:?}"), - } - } - - #[test] - fn parses_https_url_without_git_suffix() { - let slug = parse_slug("https://github.com/owner/repo").unwrap(); - assert_eq!(slug.repo, "repo"); - } - - #[test] - fn parses_https_url_with_trailing_slash() { - let slug = parse_slug("https://github.com/owner/repo/").unwrap(); - assert_eq!(slug.repo, "repo"); - } - - #[test] - fn parses_ssh_url() { - let slug = parse_slug("git@github.com:owner/repo.git").unwrap(); - assert_eq!(slug.owner, "owner"); - assert_eq!(slug.repo, "repo"); - } - - #[test] - fn parses_ssh_url_without_git_suffix() { - let slug = parse_slug("git@github.com:owner/repo").unwrap(); - assert_eq!(slug.repo, "repo"); - } - - #[test] - fn invalid_url_errors() { - let err = parse_slug("not-a-url").unwrap_err(); - match err { - CliError::InvalidState(msg) => { - assert!( - msg.contains("not parseable") || msg.contains("invalid"), - "got: {msg}" - ); - } - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn empty_components_error() { - let err = parse_slug("https://github.com//repo").unwrap_err(); - match err { - CliError::InvalidState(_) => {} - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn check_local_branch_accepts_non_generated_names() { - check_local_branch("feature", "stack/me").unwrap(); - check_local_branch("stack/me", "stack/me").unwrap(); - } - - #[test] - fn check_local_branch_rejects_slug_suffix() { - let err = check_local_branch("stack/me/feature/feat-x--abcd1234", "stack/me").unwrap_err(); - match err { - CliError::InvalidState(msg) => assert!(msg.contains("generated"), "got: {msg}"), - other => panic!("unexpected: {other:?}"), - } - } - - #[test] - fn check_local_branch_rejects_changeid_suffix() { - let err = check_local_branch( - "stack/me/feature/Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "stack/me", - ) - .unwrap_err(); - match err { - CliError::InvalidState(_) => {} - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-stack/src/sync_status.rs b/crates/mergify-stack/src/sync_status.rs deleted file mode 100644 index 23c9b6f2..00000000 --- a/crates/mergify-stack/src/sync_status.rs +++ /dev/null @@ -1,263 +0,0 @@ -//! Sync-specific stack classifier. -//! -//! Walks the local stack commits and matches each one to a remote -//! pull request by `Change-Id`. Buckets them into: -//! -//! - **Merged** — the PR is closed-and-merged AND its head SHA -//! matches the local commit. These can be safely dropped from -//! the stack: the trunk already contains them. -//! - **Remaining** — everything else (open PR, no PR, amended- -//! after-merge). These survive the sync rebase. -//! -//! Ported from the merged-vs-remaining slice of -//! `mergify_cli/stack/changes.py::get_changes`. The full -//! create/update/skip-up-to-date classifier needed by -//! `stack push`/`stack list` lands with those slices. - -use mergify_core::CliError; -use serde_json::Value; - -use crate::local_commits::LocalCommit; -use crate::remote_changes::RemoteChange; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MergedCommit { - pub commit_sha: String, - pub title: String, - pub pull_number: u64, - pub pull_url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RemainingCommit { - pub commit_sha: String, - pub title: String, -} - -#[derive(Debug, Clone)] -pub struct SyncStatus { - pub branch: String, - pub trunk: String, - pub merged: Vec<MergedCommit>, - pub remaining: Vec<RemainingCommit>, -} - -impl SyncStatus { - /// True when every commit in the stack has been merged. - #[must_use] - pub fn all_merged(&self) -> bool { - !self.merged.is_empty() && self.remaining.is_empty() - } - - /// True when there are no merged commits — nothing to rebase away. - #[must_use] - pub fn up_to_date(&self) -> bool { - self.merged.is_empty() - } -} - -/// Classify each local commit. Returns `SyncStatus` with -/// `merged`/`remaining` buckets populated. -/// -/// `remote_changes` is consumed: each matched pull is removed -/// from the working list so a Change-Id is never double-counted. -pub fn classify( - branch: String, - trunk: String, - local_commits: &[LocalCommit], - mut remote_changes: Vec<RemoteChange>, -) -> Result<SyncStatus, CliError> { - let mut merged = Vec::new(); - let mut remaining = Vec::new(); - - for local in local_commits { - let pull = pop_matching(&mut remote_changes, &local.change_id); - - // Sync only cares about the merged-vs-not bucket — defer - // the rest of Python's action enum (create/update/skip-up- - // to-date/…) to the full classifier when stack push lands. - let is_merged = match &pull { - Some(p) => { - let merged_at = p.pull.get("merged_at"); - let head_sha = p.pull.pointer("/head/sha").and_then(Value::as_str); - merged_at.is_some_and(|v| !v.is_null()) - && head_sha == Some(local.commit_sha.as_str()) - } - None => false, - }; - - if is_merged { - let pull = pull.expect("matched a pull above"); - let pull_number = pull.pull.get("number").and_then(Value::as_u64).unwrap_or(0); - let pull_url = pull - .pull - .get("html_url") - .and_then(Value::as_str) - .map(str::to_owned) - .unwrap_or_default(); - merged.push(MergedCommit { - commit_sha: local.commit_sha.clone(), - title: local.title.clone(), - pull_number, - pull_url, - }); - } else { - remaining.push(RemainingCommit { - commit_sha: local.commit_sha.clone(), - title: local.title.clone(), - }); - } - } - - Ok(SyncStatus { - branch, - trunk, - merged, - remaining, - }) -} - -/// Pop the first remote change whose Change-Id matches `local_changeid` -/// by exact or cross-prefix comparison. Mirrors Python's -/// `pop_remote_change`: handles the asymmetric case where the -/// remote carries a full `I<40hex>` while the local carries the -/// short `<8hex>` form (from new-style branch suffixes) and vice -/// versa. -fn pop_matching( - remote_changes: &mut Vec<RemoteChange>, - local_changeid: &str, -) -> Option<RemoteChange> { - // Exact match first — the common case. - if let Some(idx) = remote_changes - .iter() - .position(|c| c.change_id == local_changeid) - { - return Some(remote_changes.swap_remove(idx)); - } - // Cross-prefix match: strip the leading `I` from both sides - // and check if either prefixes the other. - let local_hex = local_changeid.strip_prefix('I').unwrap_or(local_changeid); - let idx = remote_changes.iter().position(|c| { - let remote_hex = c.change_id.strip_prefix('I').unwrap_or(&c.change_id); - remote_hex.starts_with(local_hex) || local_hex.starts_with(remote_hex) - })?; - Some(remote_changes.swap_remove(idx)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn local(sha: &str, change_id: &str, title: &str) -> LocalCommit { - LocalCommit { - commit_sha: sha.to_string(), - title: title.to_string(), - message: String::new(), - change_id: change_id.to_string(), - slug: format!("{title}--00000000"), - note: String::new(), - } - } - - fn pull_merged(change_id: &str, head_sha: &str, number: u64) -> RemoteChange { - RemoteChange { - change_id: change_id.to_string(), - pull: json!({ - "number": number, - "state": "closed", - "merged_at": "2025-01-01T00:00:00Z", - "head": {"sha": head_sha, "ref": "stack/x"}, - "base": {"ref": "main"}, - "html_url": format!("https://github.com/o/r/pull/{number}"), - }), - } - } - - fn pull_open(change_id: &str, head_sha: &str, number: u64) -> RemoteChange { - RemoteChange { - change_id: change_id.to_string(), - pull: json!({ - "number": number, - "state": "open", - "merged_at": null, - "head": {"sha": head_sha, "ref": "stack/x"}, - "base": {"ref": "main"}, - "html_url": format!("https://github.com/o/r/pull/{number}"), - }), - } - } - - #[test] - fn merged_commit_is_classified_as_merged() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", cid, "feat: A")]; - let remote = vec![pull_merged(cid, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert_eq!(status.merged.len(), 1); - assert_eq!(status.merged[0].pull_number, 42); - assert!(status.remaining.is_empty()); - } - - #[test] - fn amended_after_merge_stays_in_remaining() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - // PR merged at head=abc; local commit is amended to def. - let commits = vec![local("def456", cid, "feat: A")]; - let remote = vec![pull_merged(cid, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert!(status.merged.is_empty()); - assert_eq!(status.remaining.len(), 1); - } - - #[test] - fn open_pr_stays_remaining() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", cid, "feat: A")]; - let remote = vec![pull_open(cid, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert!(status.merged.is_empty()); - assert_eq!(status.remaining.len(), 1); - } - - #[test] - fn no_matching_pr_stays_remaining() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", cid, "feat: A")]; - let status = classify("feat".into(), "origin/main".into(), &commits, vec![]).unwrap(); - assert!(status.merged.is_empty()); - assert_eq!(status.remaining.len(), 1); - } - - #[test] - fn cross_prefix_match_works() { - // Local has short hex Change-Id (Iaaaaaaaa, 8 hex), remote - // has full form. The match still succeeds. - let short = "Iaaaaaaaa"; - let full = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", short, "feat: A")]; - let remote = vec![pull_merged(full, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert_eq!(status.merged.len(), 1); - } - - #[test] - fn all_merged_predicate() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", cid, "feat: A")]; - let remote = vec![pull_merged(cid, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert!(status.all_merged()); - assert!(!status.up_to_date()); - } - - #[test] - fn up_to_date_predicate_when_no_merged() { - let cid = "Iaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01"; - let commits = vec![local("abc123", cid, "feat: A")]; - let remote = vec![pull_open(cid, "abc123", 42)]; - let status = classify("feat".into(), "origin/main".into(), &commits, remote).unwrap(); - assert!(!status.all_merged()); - assert!(status.up_to_date()); - } -} diff --git a/crates/mergify-stack/src/test_env.rs b/crates/mergify-stack/src/test_env.rs deleted file mode 100644 index 9df3bf16..00000000 --- a/crates/mergify-stack/src/test_env.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Test-only helper to isolate spawned `git` children from the -//! caller's `~/.gitconfig` and system config. -//! -//! Without this, parallel tests can race against each other when -//! the user's global git config carries hooks, includes, or -//! template directories that mutate per-invocation state. Symptom -//! is sporadic `git <foo> failed` panics in otherwise-pure tests -//! that just happen to spawn git as a side effect. -//! -//! Nothing here mutates the process environment — `mergify_core::env` -//! says why, and the rest of the workspace is being moved onto the -//! same footing — so this cannot be a `set_var` at process start. Instead [`isolated_git`] returns a fresh `Command` -//! with `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` -//! already on it. `Command::env` sets the *child's* environment, so -//! each git invocation carries the isolation itself; nothing is -//! shared and nothing has to be restored. -//! -//! It only covers the git commands that go through it. A `git` child -//! spawned by production code under test builds its own environment -//! from ours and sees neither these variables nor a test overlay, so -//! a fixture that needs isolation must create its repository state -//! through this helper. -//! -//! Practically: call [`isolated_git`] wherever the tests used to -//! call `std::process::Command::new("git")`. - -use std::process::Command; - -/// Build a `git` command with both `GIT_CONFIG_GLOBAL` and -/// `GIT_CONFIG_NOSYSTEM` set so it ignores the caller's user and -/// system git configuration. -pub fn isolated_git() -> Command { - let mut cmd = Command::new("git"); - cmd.env("GIT_CONFIG_GLOBAL", "/dev/null"); - cmd.env("GIT_CONFIG_NOSYSTEM", "1"); - cmd -} diff --git a/crates/mergify-stack/src/trunk.rs b/crates/mergify-stack/src/trunk.rs deleted file mode 100644 index 020e3a3e..00000000 --- a/crates/mergify-stack/src/trunk.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Resolve the "trunk" branch — the `<remote>/<branch>` a stack -//! should land on. Ported from `mergify_cli/utils.py::get_trunk` -//! and the small `git` helpers it leans on. -//! -//! Precedence matches the Python implementation: -//! 1. `branch.<current>.remote` + `branch.<current>.merge` git -//! config keys (set when the local branch tracks an upstream). -//! 2. Either or both missing → fall back to `origin/HEAD` via -//! `git symbolic-ref refs/remotes/origin/HEAD`, then *set* the -//! upstream tracking on the current branch and print a notice. -//! 3. Origin HEAD also missing → `CliError::Generic` so callers can -//! point the user at `git branch --set-upstream-to`. -//! -//! The set-upstream side effect mirrors `utils.get_trunk` exactly: -//! a Python regression test (`tests/test_utils.py`) pins the -//! behavior, so the Rust port has to preserve it. - -use std::path::Path; -use std::process::Command; - -use mergify_core::CliError; - -/// Outcome of [`get_trunk`] — the resolved `<remote>/<branch>` plus -/// whether tracking was auto-set as a side effect. Callers print a -/// notice when `tracking_set` is true. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Trunk { - pub remote: String, - pub branch: String, - /// True when we had to set `branch.<current>.{remote,merge}` as - /// a side effect (Python prints a yellow "Upstream not set …" - /// notice in that case). - pub tracking_set: bool, - /// The local branch the trunk was resolved for. Carried so the - /// caller can render the notice without re-querying git. - pub current_branch: String, -} - -impl Trunk { - /// `<remote>/<branch>` — the form callers pass to `git fetch` - /// and `git checkout --track -b`. - #[must_use] - pub fn refspec(&self) -> String { - format!("{}/{}", self.remote, self.branch) - } -} - -/// Resolve the trunk for the current branch in *`repo_dir`* (or -/// the process CWD when `None`). See module docs for the -/// precedence chain. -pub fn get_trunk(repo_dir: Option<&Path>) -> Result<Trunk, CliError> { - let current_branch = git_get_branch_name(repo_dir) - .map_err(|e| CliError::Generic(format!("can't get the current branch: {e}")))?; - - let target_branch = git_get_target_branch(repo_dir, ¤t_branch).ok(); - let target_remote = git_get_target_remote(repo_dir, ¤t_branch).ok(); - - if let (Some(branch), Some(remote)) = (target_branch.clone(), target_remote.clone()) { - return Ok(Trunk { - remote, - branch, - tracking_set: false, - current_branch, - }); - } - - let (default_remote, default_branch) = get_default_remote_branch(repo_dir).map_err(|e| { - // Mirror Python's `get_trunk`: name the branch in a - // ready-to-run `git branch … --set-upstream-to=` hint so the - // user can fix the missing tracking without guessing the - // syntax. `<remote>/<branch>` stay literal — we couldn't - // resolve them, which is exactly why we're erroring. - CliError::Generic(format!( - "can't detect the remote target branch for {current_branch}: {e}. \ - Please set it with `git branch {current_branch} \ - --set-upstream-to=<remote>/<branch>`" - )) - })?; - - let branch = target_branch.unwrap_or(default_branch); - let remote = target_remote.unwrap_or(default_remote); - - git_set_upstream(repo_dir, ¤t_branch, &remote, &branch).map_err(|e| { - CliError::Generic(format!("failed to set upstream on {current_branch}: {e}")) - })?; - - Ok(Trunk { - remote, - branch, - tracking_set: true, - current_branch, - }) -} - -/// `git rev-parse --abbrev-ref HEAD` — the short name of the -/// currently checked out branch. -pub fn git_get_branch_name(repo_dir: Option<&Path>) -> Result<String, CliError> { - run_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"]) -} - -/// `branch.<name>.merge` — the upstream ref the branch tracks, with -/// the `refs/heads/` prefix stripped to match Python's behavior. -pub fn git_get_target_branch(repo_dir: Option<&Path>, branch: &str) -> Result<String, CliError> { - let key = format!("branch.{branch}.merge"); - let value = run_git(repo_dir, &["config", "--get", &key])?; - Ok(value - .strip_prefix("refs/heads/") - .map_or_else(|| value.clone(), str::to_owned)) -} - -/// `branch.<name>.remote` — the remote the branch tracks. -pub fn git_get_target_remote(repo_dir: Option<&Path>, branch: &str) -> Result<String, CliError> { - let key = format!("branch.{branch}.remote"); - run_git(repo_dir, &["config", "--get", &key]) -} - -/// `git symbolic-ref refs/remotes/origin/HEAD` → `(remote, branch)`. -/// Used when the current branch has no upstream tracking set. -fn get_default_remote_branch(repo_dir: Option<&Path>) -> Result<(String, String), CliError> { - let raw = run_git(repo_dir, &["symbolic-ref", "refs/remotes/origin/HEAD"])?; - let stripped = raw.strip_prefix("refs/remotes/").unwrap_or(&raw); - let (remote, branch) = stripped - .split_once('/') - .ok_or_else(|| CliError::Generic(format!("unexpected origin/HEAD ref shape: {raw}")))?; - Ok((remote.to_owned(), branch.to_owned())) -} - -fn git_set_upstream( - repo_dir: Option<&Path>, - branch: &str, - remote: &str, - target_branch: &str, -) -> Result<(), CliError> { - let upstream = format!("{remote}/{target_branch}"); - run_git( - repo_dir, - &["branch", branch, "--set-upstream-to", &upstream], - ) - .map(|_| ()) -} - -fn run_git(repo_dir: Option<&Path>, args: &[&str]) -> Result<String, CliError> { - let mut cmd = Command::new("git"); - if let Some(dir) = repo_dir { - cmd.arg("-C").arg(dir); - } - cmd.args(args); - let output = cmd - .output() - .map_err(|e| CliError::Generic(format!("failed to spawn `git {}`: {e}", args.join(" "))))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(CliError::Generic(format!( - "`git {}` failed: {}", - args.join(" "), - if stderr.is_empty() { - "no stderr".to_string() - } else { - stderr - }, - ))); - } - let stdout = String::from_utf8(output.stdout).map_err(|e| { - CliError::Generic(format!("`git {}` output is not UTF-8: {e}", args.join(" "))) - })?; - Ok(stdout.trim_end().to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn init_repo() -> TempDir { - let dir = tempfile::tempdir().unwrap(); - for args in [ - &["init", "-q", "-b", "main"][..], - &["config", "user.email", "test@example.com"], - &["config", "user.name", "Test"], - &["commit", "--allow-empty", "-m", "root"], - ] { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir.path()) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?} failed"); - } - dir - } - - fn run(dir: &Path, args: &[&str]) { - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(dir) - .args(args) - .status() - .unwrap() - .success(); - assert!(ok, "git {args:?} failed"); - } - - #[test] - fn branch_name_returns_short_name() { - let dir = init_repo(); - let name = git_get_branch_name(Some(dir.path())).unwrap(); - assert_eq!(name, "main"); - } - - #[test] - fn target_branch_strips_refs_heads_prefix() { - let dir = init_repo(); - run( - dir.path(), - &["config", "branch.main.merge", "refs/heads/develop"], - ); - let v = git_get_target_branch(Some(dir.path()), "main").unwrap(); - assert_eq!(v, "develop"); - } - - #[test] - fn target_remote_reads_config() { - let dir = init_repo(); - run(dir.path(), &["config", "branch.main.remote", "upstream"]); - let v = git_get_target_remote(Some(dir.path()), "main").unwrap(); - assert_eq!(v, "upstream"); - } - - #[test] - fn get_trunk_uses_tracking_when_set() { - let dir = init_repo(); - run( - dir.path(), - &["config", "branch.main.merge", "refs/heads/release"], - ); - run(dir.path(), &["config", "branch.main.remote", "upstream"]); - let trunk = get_trunk(Some(dir.path())).unwrap(); - assert_eq!(trunk.remote, "upstream"); - assert_eq!(trunk.branch, "release"); - assert!(!trunk.tracking_set); - assert_eq!(trunk.refspec(), "upstream/release"); - } - - #[test] - fn get_trunk_falls_back_to_origin_head() { - // Simulate `origin/HEAD -> origin/main` with a bare upstream. - let upstream_dir = tempfile::tempdir().unwrap(); - let ok = crate::test_env::isolated_git() - .arg("-C") - .arg(upstream_dir.path()) - .args(["init", "-q", "--bare", "-b", "main"]) - .status() - .unwrap() - .success(); - assert!(ok); - - let dir = init_repo(); - // Wire up `origin` and seed it with a commit on `main`. - run( - dir.path(), - &[ - "remote", - "add", - "origin", - upstream_dir.path().to_str().unwrap(), - ], - ); - run(dir.path(), &["push", "-q", "origin", "main"]); - // `git remote set-head` writes refs/remotes/origin/HEAD. - run(dir.path(), &["remote", "set-head", "origin", "main"]); - // Now create a local branch with no tracking and check it out. - run(dir.path(), &["checkout", "-q", "-b", "feature"]); - - let trunk = get_trunk(Some(dir.path())).unwrap(); - assert_eq!(trunk.remote, "origin"); - assert_eq!(trunk.branch, "main"); - assert!(trunk.tracking_set); - assert_eq!(trunk.current_branch, "feature"); - - // Side effect: `branch.feature.{remote,merge}` should now be - // set. Re-running should report `tracking_set = false`. - let trunk2 = get_trunk(Some(dir.path())).unwrap(); - assert!(!trunk2.tracking_set); - assert_eq!(trunk2.remote, "origin"); - assert_eq!(trunk2.branch, "main"); - } - - #[test] - fn get_trunk_errors_when_no_upstream_and_no_origin_head() { - let dir = init_repo(); - let err = get_trunk(Some(dir.path())).unwrap_err(); - match err { - CliError::Generic(msg) => { - assert!(msg.contains("can't detect the remote target branch")); - } - other => panic!("unexpected: {other:?}"), - } - } -} diff --git a/crates/mergify-test-support/Cargo.toml b/crates/mergify-test-support/Cargo.toml deleted file mode 100644 index a276da61..00000000 --- a/crates/mergify-test-support/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "mergify-test-support" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Shared test scaffolding for the mergify CLI Rust port. Not published." -publish = false - -[dependencies] -mergify-core = { path = "../mergify-core" } - -[lints] -workspace = true diff --git a/crates/mergify-test-support/src/lib.rs b/crates/mergify-test-support/src/lib.rs deleted file mode 100644 index 97c7def4..00000000 --- a/crates/mergify-test-support/src/lib.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Shared test scaffolding for the mergify CLI Rust port. -//! -//! Every ported command writes through a `&mut dyn Output`, so tests -//! need a way to feed it an in-memory sink and read back what the -//! command produced. Every test module used to re-roll the same -//! `SharedBytes` / `SharedWriter` / `Captured` / `make_output` trio -//! by hand — about 30 LOC × ~15 files of pure boilerplate, drifting -//! over time (some had `stderr`, some didn't; some named fields, some -//! used helper functions). This crate is the one canonical version. -//! -//! Typical usage: -//! -//! ```ignore -//! use mergify_test_support::Captured; -//! -//! let mut cap = Captured::human(); -//! run(options, &mut cap.output).await.unwrap(); -//! assert!(cap.stdout().contains("ok")); -//! ``` -//! -//! Lives as a separate `dev-dependencies` crate (rather than a feature -//! on `mergify-core`) so the test-only types never leak into a -//! production build. - -use std::io::Write; -use std::sync::Arc; -use std::sync::Mutex; - -use mergify_core::OutputMode; -use mergify_core::StdioOutput; - -/// Mutex-protected, `Arc`-shared byte buffer the captured output is -/// streamed into. Exposed so tests that need to take a snapshot -/// mid-run (or share the buffer across threads) can grab a clone -/// of the `Arc`. -pub type SharedBytes = Arc<Mutex<Vec<u8>>>; - -/// `Write` adapter over a [`SharedBytes`] handle. Used as the -/// stdout/stderr sink for [`StdioOutput::with_sinks`]. -pub struct SharedWriter(pub SharedBytes); - -impl Write for SharedWriter { - fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -/// In-memory capture of an [`StdioOutput`] for tests. -/// -/// Construct with [`Captured::human`] or [`Captured::new`], pass -/// `&mut cap.output` to the command under test, then assert on the -/// recorded bytes via [`Captured::stdout`] / [`Captured::stderr`]. -pub struct Captured { - pub output: StdioOutput, - stdout: SharedBytes, - stderr: SharedBytes, -} - -impl Captured { - /// Capture an [`OutputMode::Human`] output. The most common - /// case — every command renders human output by default and - /// only flips to JSON when the user passes `--json`. - #[must_use] - pub fn human() -> Self { - Self::new(OutputMode::Human) - } - - /// Capture with an explicit [`OutputMode`]. - #[must_use] - pub fn new(mode: OutputMode) -> Self { - let stdout: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let stderr: SharedBytes = Arc::new(Mutex::new(Vec::new())); - let output = StdioOutput::with_sinks( - mode, - SharedWriter(Arc::clone(&stdout)), - SharedWriter(Arc::clone(&stderr)), - ); - Self { - output, - stdout, - stderr, - } - } - - /// Captured stdout as a UTF-8 string. Panics if the captured - /// bytes aren't valid UTF-8 — tests shouldn't be producing - /// invalid UTF-8 in the first place, and panicking with a clear - /// site beats silently lossy `String::from_utf8_lossy`. - #[must_use] - pub fn stdout(&self) -> String { - String::from_utf8(self.stdout.lock().unwrap().clone()) - .expect("captured stdout must be UTF-8") - } - - /// Captured stderr as a UTF-8 string. See [`Self::stdout`] for - /// the UTF-8 caveat. - #[must_use] - pub fn stderr(&self) -> String { - String::from_utf8(self.stderr.lock().unwrap().clone()) - .expect("captured stderr must be UTF-8") - } - - /// Snapshot the captured stdout bytes (without UTF-8 validation). - /// Useful when the command writes a binary or pre-encoded payload - /// the test wants to compare byte-for-byte. - #[must_use] - pub fn stdout_bytes(&self) -> Vec<u8> { - self.stdout.lock().unwrap().clone() - } -} diff --git a/crates/mergify-tui/Cargo.toml b/crates/mergify-tui/Cargo.toml deleted file mode 100644 index 86b4ffbe..00000000 --- a/crates/mergify-tui/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "mergify-tui" -version = "0.0.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -authors.workspace = true -description = "Reusable terminal-UI primitives for mergify-cli: ANSI styling, relative-time formatter, tree-drawing characters." -publish = false - -[dependencies] -anstyle = { workspace = true } -chrono = { workspace = true } -# Cursor restore on the picker's error/SIGINT paths — same version -# dialoguer already pulls in. -console = { workspace = true } -# SIGINT-during-picker handling: restore the cursor and exit 130 -# before console's raw-mode key reader re-raises the signal. -ctrlc = { workspace = true } -# `select::fuzzy_select` — fzf-style picker for `stack open`. Kept -# behind this crate's wrapper so command crates never depend on -# dialoguer directly. fuzzy-select only: the editor/password -# default features are dead weight here. -dialoguer = { workspace = true } - -[lints] -workspace = true diff --git a/crates/mergify-tui/src/glyph.rs b/crates/mergify-tui/src/glyph.rs deleted file mode 100644 index c3919c9f..00000000 --- a/crates/mergify-tui/src/glyph.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Pairing of a Unicode icon with its [`anstyle::Style`]. -//! -//! Multiple commands map an enum-ish state code (CI check states, -//! merge-queue batch states, …) to "render this icon in that color". -//! Both halves of that pair travel together at every call site: the -//! icon goes in the formatted output, the style wraps it. Bundling -//! them into one named type beats returning a `(&str, Style)` tuple -//! — the field names document what's what at the call site, and -//! future fields (e.g. a dim suffix) can be added without rewriting -//! every consumer. - -use anstyle::Style; - -#[derive(Clone, Copy)] -pub struct StyledGlyph { - pub icon: &'static str, - pub style: Style, -} - -impl StyledGlyph { - /// Convenience constructor — saves a few `StyledGlyph { … }` - /// braces at the dense pattern-match call sites. - #[must_use] - pub const fn new(icon: &'static str, style: Style) -> Self { - Self { icon, style } - } -} diff --git a/crates/mergify-tui/src/lib.rs b/crates/mergify-tui/src/lib.rs deleted file mode 100644 index 816cf067..00000000 --- a/crates/mergify-tui/src/lib.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Terminal-UI primitives shared across the ported `mergify` -//! commands. -//! -//! Each command renders its own bespoke layout, but the building -//! blocks — color/TTY detection, relative-time formatter, tree -//! characters — are uniform. Centralizing them here keeps the -//! visual style consistent across `queue status`, `queue show`, -//! `freeze list`, and any future command that needs structured -//! human-readable output. -//! -//! Modules: -//! -//! - [`theme`]: [`Theme`] struct that wraps `anstyle::Style` with -//! choice-and-TTY-aware enable/disable, plus a named-color -//! palette. This crate reads no environment variables; the -//! `NO_COLOR` family reaches it folded into the recorded -//! [`ColorChoice`]. The same closure-based emit code paths produce -//! styled output on a TTY and plain text everywhere else with no -//! conditional branching at every write. -//! - [`glyph`]: [`StyledGlyph`] — pairs a Unicode icon with the -//! [`anstyle::Style`] it's drawn in. Used by commands that map -//! state codes (check states, batch states, …) to a small visual -//! token. -//! - [`time`]: [`relative_time`](time::relative_time) formats an -//! ISO-8601/RFC-3339 timestamp as a coarse delta (`Ns` / `Nm` / -//! `Nh` / `Nd`), with `~…` / `… ago` decorators for -//! future/past. Returns an empty string on parse failure rather -//! than panicking — degrading gracefully matches the Python -//! originals' behavior. -//! - [`tree`]: Unicode box-drawing constants -//! ([`BRANCH`](tree::BRANCH), [`LAST_BRANCH`](tree::LAST_BRANCH), -//! [`CONTINUATION`](tree::CONTINUATION), -//! [`LAST_CONTINUATION`](tree::LAST_CONTINUATION)) and the -//! [`branch_chars`](tree::branch_chars) helper. -//! - [`select`]: [`fuzzy_select`](select::fuzzy_select) — fzf-style -//! fuzzy picker over a list of labels, wrapping -//! `dialoguer::FuzzySelect` behind this crate's boundary. - -pub mod glyph; -pub mod select; -pub mod theme; -pub mod time; -pub mod tree; - -pub use glyph::StyledGlyph; -pub use select::fuzzy_select; -pub use theme::{ColorChoice, Theme, set_color_choice}; -pub use time::relative_time; diff --git a/crates/mergify-tui/src/select.rs b/crates/mergify-tui/src/select.rs deleted file mode 100644 index 20581614..00000000 --- a/crates/mergify-tui/src/select.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Interactive fzf-style fuzzy picker. -//! -//! Thin boundary around `dialoguer::FuzzySelect` (whose matcher is -//! `fuzzy-matcher`'s `SkimMatcherV2` — skim is the Rust fzf clone) -//! so command crates never depend on dialoguer directly: the crate -//! stays swappable, and every future picker (`stack checkout`, -//! `stack edit`, …) shares one entry point with a single theming -//! and cancellation policy. - -use std::io; -use std::sync::Once; -use std::sync::atomic::{AtomicBool, Ordering}; - -use console::Term; -use dialoguer::FuzzySelect; -use dialoguer::theme::{ColorfulTheme, SimpleTheme, Theme}; - -use crate::theme::colors_enabled; - -/// Set while a picker's `interact_opt()` call is on the stack, so the -/// SIGINT handler knows whether the terminal is currently in the -/// state dialoguer left it in (cursor hidden, raw mode) and needs -/// restoring. -static PICKER_ACTIVE: AtomicBool = AtomicBool::new(false); -static INSTALL_SIGINT: Once = Once::new(); - -/// Ctrl-C inside the picker must restore the terminal before the -/// process dies: console's key reader re-raises SIGINT after -/// restoring termios, but nothing un-hides the cursor dialoguer -/// hid. The handler exits 130 (128+SIGINT), the shell convention -/// fzf itself follows. -fn install_sigint_handler() { - INSTALL_SIGINT.call_once(|| { - // If installation fails (e.g. some embedder already owns - // the handler), we keep console's raise semantics: death - // by SIGINT, possibly with a hidden cursor — no worse - // than before this handler existed. - let _ = ctrlc::set_handler(|| { - if PICKER_ACTIVE.load(Ordering::SeqCst) { - let _ = Term::stderr().show_cursor(); - } - std::process::exit(130); - }); - }); -} - -/// Run an fzf-style fuzzy picker over `items` on the controlling -/// terminal: type to filter, arrows to move, Enter to accept. -/// `default` is the index preselected before any filtering. -/// -/// Labels should be unique — dialoguer maps the selected label back -/// to an index by string equality, so duplicate labels resolve to -/// the first occurrence. -/// -/// Ctrl-C normally exits the process directly with status 130 (the -/// fzf convention) via a process-global SIGINT handler installed on -/// first use, which also restores the cursor dialoguer hides during -/// interaction. `Ok(None)` cancellation from an -/// [`io::ErrorKind::Interrupted`] read error is defensive fallback -/// for when that handler is inert — SIGINT ignored, or installation -/// lost a race with another handler owner (e.g. under `nohup`). -/// -/// Callers must only invoke this when stdin, stdout, and stderr are -/// all TTYs — dialoguer renders the list on stderr; there is no -/// non-interactive fallback at this layer. -pub fn fuzzy_select(prompt: &str, items: &[String], default: usize) -> io::Result<Option<usize>> { - install_sigint_handler(); - let colorful = ColorfulTheme::default(); - let simple = SimpleTheme; - // Same color policy as every other renderer — the choice the - // entry point recorded, else the TTY — reused from `theme.rs` - // rather than re-derived. - let theme: &dyn Theme = if colors_enabled() { &colorful } else { &simple }; - PICKER_ACTIVE.store(true, Ordering::SeqCst); - let result = FuzzySelect::with_theme(theme) - .with_prompt(prompt) - .items(items) - .default(default) - .highlight_matches(true) - // The caller prints its own confirmation line; suppress - // dialoguer's post-selection echo so nothing shows twice. - .report(false) - .interact_opt(); - if result.is_err() { - // dialoguer restores the cursor on its Enter/Esc paths but - // not when the key read errors. Restore BEFORE clearing - // PICKER_ACTIVE: a SIGINT handler that observes `false` is - // then guaranteed the restore already happened, and one - // that observes `true` restores it itself — a double - // restore is harmless, a missed one leaves the shell - // cursorless. - let _ = Term::stderr().show_cursor(); - } - PICKER_ACTIVE.store(false, Ordering::SeqCst); - map_interact_result(result) -} - -/// Map dialoguer's interact result onto the wrapper's contract: -/// cancellation becomes `Ok(None)`, everything else passes through. -fn map_interact_result( - result: Result<Option<usize>, dialoguer::Error>, -) -> io::Result<Option<usize>> { - match result { - Ok(selection) => Ok(selection), - Err(dialoguer::Error::IO(err)) => { - if err.kind() == io::ErrorKind::Interrupted { - Ok(None) - } else { - Err(err) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn selection_and_native_cancel_pass_through() { - assert_eq!(map_interact_result(Ok(Some(2))).unwrap(), Some(2)); - assert_eq!(map_interact_result(Ok(None)).unwrap(), None); - } - - #[test] - fn interrupted_read_is_cancel_fallback() { - // Normally Ctrl-C exits the process directly via the SIGINT - // handler (see `install_sigint_handler`) and this mapping - // never runs; it only fires when that handler is inert. - let interrupted = dialoguer::Error::IO(io::Error::new( - io::ErrorKind::Interrupted, - "read interrupted", - )); - assert_eq!(map_interact_result(Err(interrupted)).unwrap(), None); - } - - #[test] - fn other_io_errors_propagate() { - let broken = dialoguer::Error::IO(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe")); - let err = map_interact_result(Err(broken)).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::BrokenPipe); - } -} diff --git a/crates/mergify-tui/src/theme.rs b/crates/mergify-tui/src/theme.rs deleted file mode 100644 index ed057732..00000000 --- a/crates/mergify-tui/src/theme.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! ANSI styling, enabled by the recorded [`ColorChoice`] and the TTY. -//! -//! This crate reads no environment variable of its own: the -//! `NO_COLOR` family arrives folded into that choice. See -//! [`set_color_choice`]. -//! -//! The intent is to write normal `format!` / `write!` code paths -//! that emit styled output on an interactive terminal and produce -//! plain text everywhere else, *without* conditional branching at -//! every call site. `anstyle::Style::new()` (the default) -//! deliberately emits no escape sequences in its `Display` impl — -//! so when [`Theme::detect`] decides colors are off, every named -//! style on the [`Theme`] is a `Style::new()` no-op and `reset` -//! is the empty string. Code reads the same in both modes. - -use std::io::IsTerminal; -use std::sync::OnceLock; - -use anstyle::AnsiColor; -use anstyle::Style; - -/// The user's resolved color preference. `Auto` defers to TTY -/// detection; `Always`/`Never` override it. The `NO_COLOR` family is -/// folded in by the caller of [`set_color_choice`], not here. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum ColorChoice { - #[default] - Auto, - Always, - Never, -} - -static COLOR_CHOICE: OnceLock<ColorChoice> = OnceLock::new(); - -/// Record the process-wide color preference, once, at startup before -/// any [`Theme::detect`]. Subsequent calls are ignored, so a stray -/// second call can't flip colors mid-run. -/// -/// Calling this is also what makes color possible at all: until it -/// does, [`Theme::detect`] reports disabled. Only the CLI entry point -/// calls it, so a process that never went through `main` — a test -/// harness, a doctest, an embedder — is never colored. -/// -/// The caller passes the *resolved* choice: `--color` with the -/// `NO_COLOR` / `FORCE_COLOR` / `CLICOLOR_FORCE` overrides already -/// folded in (`mergify-cli`'s `resolve_color_choice`). This crate -/// deliberately never reads the environment itself. Two reasons: -/// it keeps `mergify-tui` dependency-light and free of the -/// workspace's env funnel, and it means no consumer crate's test -/// binary reads the environment on a worker thread just by -/// rendering a themed line. -pub fn set_color_choice(choice: ColorChoice) { - let _ = COLOR_CHOICE.set(choice); -} - -/// Pre-built styles + reset escape, matched to the renderers in -/// the ported commands. Each field is either a real `Style` (when -/// colors are enabled) or `Style::new()` (when disabled — emits -/// nothing); `reset` mirrors that with `"\x1b[0m"` vs `""`. -/// -/// Construct via [`Theme::detect`] for the production policy (the -/// recorded choice, else the TTY; off outside the CLI entry point). -/// Tests that need to assert on styled output explicitly can pass -/// `enabled = true` to [`Theme::new`]. -pub struct Theme { - pub enabled: bool, - pub bold: Style, - pub dim: Style, - /// SGR reset escape, or empty when colors are disabled. Using - /// a `&'static str` instead of `anstyle::Reset` keeps both - /// styled and plain code paths free of escape sequences when - /// `enabled = false`. - pub reset: &'static str, - pub cyan: Style, - pub green: Style, - pub red: Style, - pub yellow: Style, - pub magenta: Style, - /// Bold + yellow. Distinct named style because it shows up in - /// every "warning"-flavored line (e.g. the queue pause - /// indicator) and nesting `{B}{Y}` at every call site is - /// noisy. - pub warn: Style, -} - -impl Theme { - /// Detect whether the process should emit colors. - /// - /// Policy: - /// - /// 1. No [`set_color_choice`] yet ⇒ disabled. Only the CLI entry - /// point records one, so this is a test harness or an embedder: - /// it asserts on in-memory buffers and must not take a - /// dependency on the developer's terminal. - /// 2. `--color always`/`never` (via [`set_color_choice`]) wins. - /// 3. Otherwise (`auto`): `stdout` must be a terminal. - /// - /// `NO_COLOR` / `FORCE_COLOR` / `CLICOLOR_FORCE` are **not** read - /// here — see [`set_color_choice`]. - #[must_use] - pub fn detect() -> Self { - Self::new(colors_enabled()) - } - - /// Construct with explicit `enabled`. Tests use this to - /// deterministically exercise the styled or plain branch. - #[must_use] - pub fn new(enabled: bool) -> Self { - let on = |style: Style| if enabled { style } else { Style::new() }; - Self { - enabled, - bold: on(Style::new().bold()), - dim: on(Style::new().dimmed()), - reset: if enabled { "\x1b[0m" } else { "" }, - cyan: on(Style::new().fg_color(Some(AnsiColor::Cyan.into()))), - green: on(Style::new().fg_color(Some(AnsiColor::Green.into()))), - red: on(Style::new().fg_color(Some(AnsiColor::Red.into()))), - yellow: on(Style::new().fg_color(Some(AnsiColor::Yellow.into()))), - magenta: on(Style::new().fg_color(Some(AnsiColor::Magenta.into()))), - warn: on(Style::new().bold().fg_color(Some(AnsiColor::Yellow.into()))), - } - } - - /// Build an arbitrary foreground color [`Style`] honoring the - /// theme's enabled flag. Useful when a renderer maps domain - /// state (status code, severity, …) to a color and the named - /// fields above don't cover it. - #[must_use] - pub fn fg(&self, color: AnsiColor) -> Style { - if self.enabled { - Style::new().fg_color(Some(color.into())) - } else { - Style::new() - } - } -} - -/// Pure color decision, factored out of [`colors_enabled`] so the -/// precedence is unit-testable without touching global state or the -/// real TTY. -fn resolve_enabled(choice: Option<ColorChoice>, is_tty: bool) -> bool { - match choice { - Some(ColorChoice::Always) => true, - // `None` is nobody having recorded a preference, which means - // nobody is watching a terminal — see [`set_color_choice`]. - None | Some(ColorChoice::Never) => false, - Some(ColorChoice::Auto) => is_tty, - } -} - -pub(crate) fn colors_enabled() -> bool { - // An unset choice means we are not the CLI: colors off. This - // used to be `cfg!(test)`, which cannot do the job — it is false - // whenever this crate is compiled as a dependency, so every - // *consumer* crate's tests were reading the developer's - // environment after all. `FORCE_COLOR=1 cargo test` failed on the - // escape sequences that leaked into asserted output. - resolve_enabled(COLOR_CHOICE.get().copied(), std::io::stdout().is_terminal()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn color_precedence() { - // No recorded choice: not the CLI, so nothing is colored — - // not even on a TTY. This is what keeps a consumer crate's - // tests reproducible. - assert!(!resolve_enabled(None, true)); - // Explicit choice overrides the TTY in both directions. - assert!(resolve_enabled(Some(ColorChoice::Always), false)); - assert!(!resolve_enabled(Some(ColorChoice::Never), true)); - // Auto follows the TTY. The env overrides that used to be - // decided here now reach us already folded into the choice — - // see `set_color_choice`. - assert!(resolve_enabled(Some(ColorChoice::Auto), true)); - assert!(!resolve_enabled(Some(ColorChoice::Auto), false)); - } - - #[test] - fn disabled_theme_emits_no_escape_sequences() { - let theme = Theme::new(false); - assert_eq!(theme.reset, ""); - assert_eq!(format!("{}text{:#}", theme.bold, theme.bold), "text"); - assert_eq!(format!("{}text{:#}", theme.cyan, theme.cyan), "text"); - assert_eq!( - format!( - "{}text{:#}", - theme.fg(AnsiColor::Blue), - theme.fg(AnsiColor::Blue) - ), - "text", - ); - } - - #[test] - fn enabled_theme_wraps_with_codes() { - let theme = Theme::new(true); - assert_eq!(theme.reset, "\x1b[0m"); - // anstyle's `{:#}` prints the reset; we just need codes - // surrounding the payload. - let rendered = format!("{}text{}", theme.bold, theme.reset); - assert!(rendered.starts_with("\x1b["), "got {rendered:?}"); - assert!(rendered.contains("text")); - assert!(rendered.ends_with("\x1b[0m")); - } - - #[test] - fn fg_respects_enabled_flag() { - assert_eq!(format!("{}", Theme::new(false).fg(AnsiColor::Red)), ""); - assert!(!format!("{}", Theme::new(true).fg(AnsiColor::Red)).is_empty()); - } -} diff --git a/crates/mergify-tui/src/time.rs b/crates/mergify-tui/src/time.rs deleted file mode 100644 index b8c2f49d..00000000 --- a/crates/mergify-tui/src/time.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Coarse relative-time formatter. -//! -//! Modeled on the Python CLI's `_relative_time` helper: an -//! ISO-8601 / RFC-3339 timestamp becomes a short delta like -//! `5m ago` or `~2h`. The granularity is intentionally coarse — -//! seconds / minutes / hours / days, single component — because -//! these numbers show up in dense table layouts where exact -//! fidelity adds noise without information. -//! -//! Behavior on unparseable input is the one **intentional** -//! divergence from Python: the Python helper returned the raw -//! input verbatim, which leaked an ugly ISO timestamp into the -//! column. The Rust port returns an empty string instead, and -//! callers treat that as "skip this column" so a single -//! malformed timestamp doesn't abort the whole render. - -use chrono::DateTime; -use chrono::Utc; - -/// Format an ISO-8601 / RFC-3339 timestamp as a coarse delta from -/// `now`. -/// -/// - Past timestamps render as `"<value> ago"`. -/// - Future timestamps render as `"~<value>"` when `future = true` -/// (callers use this for ETAs to distinguish them visually from -/// "happened" times). -/// - Granularity collapses to the largest non-zero unit (`Ns`, -/// `Nm`, `Nh`, or `Nd`). -/// - Returns `""` when `iso` is not a valid RFC-3339 timestamp. -#[must_use] -pub fn relative_time(iso: &str, now: DateTime<Utc>, future: bool) -> String { - let Ok(parsed) = DateTime::parse_from_rfc3339(iso) else { - return String::new(); - }; - let parsed = parsed.with_timezone(&Utc); - let delta = (now - parsed).num_seconds().abs(); - let value = if delta < 60 { - format!("{delta}s") - } else if delta < 3600 { - format!("{}m", delta / 60) - } else if delta < 86400 { - format!("{}h", delta / 3600) - } else { - format!("{}d", delta / 86400) - }; - if future { - format!("~{value}") - } else { - format!("{value} ago") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn at(iso: &str) -> DateTime<Utc> { - DateTime::parse_from_rfc3339(iso) - .unwrap() - .with_timezone(&Utc) - } - - #[test] - fn seconds() { - assert_eq!( - relative_time("2026-01-01T00:00:30Z", at("2026-01-01T00:01:00Z"), false), - "30s ago", - ); - } - - #[test] - fn minutes() { - assert_eq!( - relative_time("2026-01-01T00:55:00Z", at("2026-01-01T01:00:00Z"), false), - "5m ago", - ); - } - - #[test] - fn hours() { - assert_eq!( - relative_time("2026-01-01T00:00:00Z", at("2026-01-01T05:00:00Z"), false), - "5h ago", - ); - } - - #[test] - fn days() { - assert_eq!( - relative_time("2026-01-01T00:00:00Z", at("2026-01-08T00:00:00Z"), false), - "7d ago", - ); - } - - #[test] - fn future_prefix() { - assert_eq!( - relative_time("2026-01-01T00:30:00Z", at("2026-01-01T00:00:00Z"), true), - "~30m", - ); - } - - #[test] - fn unparseable_returns_empty() { - // Mirrors the Python CLI: skip the column rather than - // abort the render on bad input. - assert_eq!( - relative_time("not-a-date", at("2026-01-01T00:00:00Z"), false), - "", - ); - } -} diff --git a/crates/mergify-tui/src/tree.rs b/crates/mergify-tui/src/tree.rs deleted file mode 100644 index 011da7d2..00000000 --- a/crates/mergify-tui/src/tree.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Unicode box-drawing characters for indented tree output. -//! -//! Each tree row pairs a *branch* prefix (the connector for the -//! row itself) with a *continuation* prefix (the column drawn -//! beneath the row to keep the visual lineage clear). Whether a -//! row is the last child of its parent flips both: -//! -//! ```text -//! parent -//! ├── child A ← BRANCH ┐ -//! │ └── grandchild ← CONTINUATION + LAST_ │ child A is not last, -//! ├── child B │ so its continuation -//! │ ├── grandchild │ column draws `│ ` -//! │ └── grandchild ┘ -//! └── child C ← LAST_BRANCH ┐ -//! └── grandchild ← LAST_CONTINUATION+LAST│ last child: column -//! │ collapses to spaces -//! ``` -//! -//! Use [`branch_chars`] when you have a `(is_last, ...)` decision -//! and want both prefixes back in one call. - -/// Branch connector for a non-last child: `├── `. -pub const BRANCH: &str = "├── "; - -/// Branch connector for the last child of its parent: `└── `. -pub const LAST_BRANCH: &str = "└── "; - -/// Continuation column under a non-last child (keeps the vertical -/// pipe drawn so descendants stay visually attached): `│ `. -pub const CONTINUATION: &str = "│ "; - -/// Continuation column under the last child (no more vertical -/// pipe — the lineage stops here): ` ` (four spaces). -pub const LAST_CONTINUATION: &str = " "; - -/// Pick the `(branch, continuation)` pair for a row based on -/// whether it's the last child of its parent. -#[must_use] -pub fn branch_chars(is_last: bool) -> (&'static str, &'static str) { - if is_last { - (LAST_BRANCH, LAST_CONTINUATION) - } else { - (BRANCH, CONTINUATION) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn last_child_uses_corner_and_blank_continuation() { - let (branch, cont) = branch_chars(true); - assert_eq!(branch, "└── "); - assert_eq!(cont, " "); - } - - #[test] - fn middle_child_uses_tee_and_pipe_continuation() { - let (branch, cont) = branch_chars(false); - assert_eq!(branch, "├── "); - assert_eq!(cont, "│ "); - } -} diff --git a/deny.toml b/deny.toml deleted file mode 100644 index 9c276ba6..00000000 --- a/deny.toml +++ /dev/null @@ -1,50 +0,0 @@ -# cargo-deny supply-chain policy for the workspace. -# Reproduce CI locally with `cargo deny check`. - -[advisories] -# RUSTSEC advisory database. A yanked crate in the lockfile is a -# hard fail — it signals the resolved version was pulled. -yanked = "deny" - -[bans] -# Duplicate versions of a crate are usually a feature/version-drift -# symptom; surface them as a warning rather than blocking, since a -# transitive diamond is often out of our hands. -multiple-versions = "warn" -# A wildcard ("*") version on a third-party crate is never -# intentional — but the workspace's own path deps have no version -# requirement and would register as wildcards, so exempt them. -wildcards = "deny" -allow-wildcard-paths = true -# `unsafe_code = "forbid"` stops our own code calling `setenv`, but it -# does not reach into a dependency that wraps it in a safe API. This -# blocks the one we used. It is a crate name, not a property — -# cargo-deny cannot express "nothing that calls setenv" — so a -# different wrapper would still get through; the rule itself lives in -# AGENTS.md. -deny = [ - { crate = "temp-env", reason = "use mergify_core::env::testing::with_vars — see crates/mergify-core/src/env.rs" }, -] - -[licenses] -# SPDX identifiers we accept for anything we redistribute. Keep this -# list tight; add an entry (with the crate that needs it) rather than -# loosening confidence-threshold. -allow = [ - "Apache-2.0", - "MIT", - "MIT-0", # borrow-or-share (via jsonschema) - "BSD-2-Clause", - "BSD-3-Clause", - "ISC", - "Unicode-3.0", - "CDLA-Permissive-2.0", # webpki-root-certs (via rustls) - "Zlib", - "MPL-2.0", -] -confidence-threshold = 0.9 - -[sources] -# crates.io only — no git or alternative registries slip in unnoticed. -unknown-registry = "deny" -unknown-git = "deny" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 541c2d9a..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[project] -name = "mergify_cli" -# `version` is a placeholder. The release workflow stamps it from -# the git tag (`$GITHUB_REF`) right before maturin runs — keeps the -# git tag as the source of truth for PyPI versioning, the same way -# hatch-vcs did pre-port. Cargo.toml stays at "0.0.0" too; the -# binary's `mergify --version` gets the tag through -# `MERGIFY_RELEASE_VERSION` (see `crates/mergify-cli/build.rs`). -version = "0.0.0" -description = "Mergify CLI is a tool that automates the creation and management of stacked pull requests on GitHub and handles CI results upload" -authors = [{ name = "Mergify Team", email = "hello@mergify.com" }] -# No runtime Python dependencies — the wheel ships a single Rust -# binary. `requires-python` is kept for the wheel's metadata so -# `pip install` resolves a recent-enough interpreter to actually -# run the install scripts; nothing in the binary needs Python at -# runtime. -requires-python = ">=3.10" -readme = "README.md" -license = "Apache-2.0" -keywords = ["git", "github", "pull-request", "stacked-pr", "ci", "merge"] -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Rust", - "Topic :: Software Development :: Version Control :: Git", -] - -# Empty: the binary has no Python runtime deps. -dependencies = [] - -[project.urls] -Homepage = "https://mergify.com" -Documentation = "https://docs.mergify.com" -Repository = "https://github.com/Mergifyio/mergify-cli" -Issues = "https://github.com/Mergifyio/mergify-cli/issues" -Changelog = "https://github.com/Mergifyio/mergify-cli/releases" - -[build-system] -requires = ["maturin>=1.7,<2.0"] -build-backend = "maturin" - -[tool.maturin] -# The wheel ships only the Rust binary `mergify` (built from -# `crates/mergify-cli`). The previous incarnation also bundled a -# Python `mergify_cli/` package for un-ported subcommands; every -# command is now native, so the wheel is binary-only. -bindings = "bin" -manifest-path = "crates/mergify-cli/Cargo.toml" -strip = true diff --git a/renovate.json b/renovate.json index 193f98f4..69057728 100644 --- a/renovate.json +++ b/renovate.json @@ -4,35 +4,7 @@ "config:recommended" ], "minimumReleaseAge": "7 days", - "lockFileMaintenance": { - "enabled": false - }, "packageRules": [ - { - "matchManagers": [ - "cargo" - ], - "matchUpdateTypes": [ - "lockFileMaintenance" - ], - "enabled": true, - "schedule": [ - "before 5am on monday" - ] - }, - { - "matchManagers": [ - "cargo" - ], - "matchUpdateTypes": [ - "minor", - "patch" - ], - "groupName": "Cargo minor/patch updates", - "schedule": [ - "before 5am on monday" - ] - }, { "matchManagers": [ "github-actions" diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index a1ec1381..00000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Pinned so a new stable release can't break every open PR with new -# clippy lints on untouched code. Renovate's rust-toolchain manager -# proposes bumps (after the 7-day minimumReleaseAge); a bump PR that -# trips new lints fails CI and must fix them in the same PR. -[toolchain] -channel = "1.98" -components = ["rustfmt", "clippy"]