From 6f6219128161e6322c6f6a70d1f3b1f696aaec8c Mon Sep 17 00:00:00 2001 From: jepegit Date: Sun, 19 Apr 2026 20:39:59 +0200 Subject: [PATCH] Check external CLI deps at init time and document prerequisites (#18) issue-flow's scaffolded slash commands shell out to git and gh, but that was only discoverable by running a command and hitting an error. This change surfaces the dependency list up front. - New src/issue_flow/dependencies.py: declares REQUIRED_DEPENDENCIES (git, gh) with per-OS install hints and a docs URL, exposes a pure shutil.which-based check_dependencies(), and a prompt_or_skip() that prints a rich-formatted missing-deps report and either bypasses (explicit flag or non-TTY stdin) or calls typer.confirm. - run_init and run_update gain a skip_dep_check kwarg and run the gate before any scaffolding; a decline raises typer.Exit(1). - CLI: init and update both grow a --skip-dep-check flag for automation. - README: new Prerequisites section above Installation covering git, gh (with an OS install table and a gh auth login reminder), and uv, plus notes on the new check. Option tables and the Usage snippet updated to list the flag. - HISTORY.md: new [Unreleased] bullet. - tests/conftest.py: autouse fixture stubs check_dependencies to [] by default so the rest of the suite stays deterministic across dev/CI. - New tests/test_dependencies.py (11 cases) plus four new init cases covering all-present, --skip-dep-check, non-TTY bypass, and the decline -> typer.Exit(1) with no scaffold written. uv run pytest: 64 passed. uv run ruff check src/ tests/: clean. Closes #18 Made-with: Cursor --- .../03-solved-issues/issue18_original.md | 7 + .issueflows/03-solved-issues/issue18_plan.md | 122 +++++++++++++ .../03-solved-issues/issue18_status.md | 59 +++++++ HISTORY.md | 1 + README.md | 41 ++++- src/issue_flow/cli.py | 22 ++- src/issue_flow/dependencies.py | 162 ++++++++++++++++++ src/issue_flow/init.py | 43 ++++- tests/conftest.py | 26 +++ tests/test_dependencies.py | 154 +++++++++++++++++ tests/test_init.py | 77 +++++++++ 11 files changed, 708 insertions(+), 6 deletions(-) create mode 100644 .issueflows/03-solved-issues/issue18_original.md create mode 100644 .issueflows/03-solved-issues/issue18_plan.md create mode 100644 .issueflows/03-solved-issues/issue18_status.md create mode 100644 src/issue_flow/dependencies.py create mode 100644 tests/conftest.py create mode 100644 tests/test_dependencies.py diff --git a/.issueflows/03-solved-issues/issue18_original.md b/.issueflows/03-solved-issues/issue18_original.md new file mode 100644 index 0000000..f07c678 --- /dev/null +++ b/.issueflows/03-solved-issues/issue18_original.md @@ -0,0 +1,7 @@ +# Issue #18: better documentation on dependencies + +Source: https://github.com/jepegit/issue-flow/issues/18 + +## Original issue text + +issueflow requires several dependencies. It is probably OK to assume all users have git installed. But other dependencies, especially gh should be better documented. Including how to install them, and maybe also make sure that issueflow init checks if all dependencies are present, if not issue a warning with description on how to install it (and ask for confirmation before proceeding). diff --git a/.issueflows/03-solved-issues/issue18_plan.md b/.issueflows/03-solved-issues/issue18_plan.md new file mode 100644 index 0000000..069706c --- /dev/null +++ b/.issueflows/03-solved-issues/issue18_plan.md @@ -0,0 +1,122 @@ +# Plan for issue #18: better documentation on dependencies + +## Goal + +Make issue-flow's external CLI dependencies (especially GitHub CLI `gh`) +obvious to users, both in the README and at install time: `issue-flow init` +should detect missing dependencies, print install guidance, and require +confirmation before continuing. + +## Constraints + +- Stay proportional — small utility change, no new runtime deps. +- Preserve existing `run_init` / `run_update` behavior for the happy path + (all deps present) so current tests keep passing without edits. +- Use the existing `rich.Console` for output (matches current style) and + `typer.confirm` (Typer is already a dependency) for the prompt. +- Detection must be safe on Windows, macOS, Linux — use `shutil.which` only + (no subprocess calls that could hang or prompt for creds). +- No hard failure when a dependency is missing and the user opts in — this + is a warning, not a gate. Missing deps block only if the user declines + at the prompt. +- Non-interactive callers (CI, tests) must be able to bypass the prompt + without hanging. Prefer an explicit opt-out flag plus auto-skip when + stdin is not a TTY. +- Keep the dependency list narrow and accurate: only tools issue-flow's + scaffolded workflow actually invokes (`git`, `gh`). Do not add `uv` to + the runtime check — `uv` is an install-time prerequisite, not something + the scaffold itself shells out to, so it belongs in README only. + +## Approach + +1. **Add a dependency check helper** in a small new module + `src/issue_flow/dependencies.py`: + - A typed record per dependency: `name`, `command`, `purpose`, + `install_hints` (dict of platform → short hint), `docs_url`. + - `check_dependencies() -> list[MissingDependency]` using + `shutil.which`. Returns a list of missing items (empty list when all + present). + - `format_missing_report(missing, console)` — renders a `rich` panel or + plain block listing what is missing, why it matters, and how to + install it on Windows / macOS / Linux, plus a docs link. + +2. **Wire the check into `run_init`** (`src/issue_flow/init.py`) at the + top, before `_create_issueflow_dirs`: + - Call `check_dependencies()`. + - If anything is missing: + - Print the report. + - If the new `--skip-dep-check` flag is set, or stdin is not a TTY + (i.e. `not sys.stdin.isatty()`), continue without prompting (emit + a one-line note that the check was skipped / non-interactive). + - Otherwise call `typer.confirm("Continue anyway?", default=False)`. + If the user declines, exit cleanly via `raise typer.Exit(code=1)`. + - If everything is present, optionally print a single dim + "All dependencies detected" line (keeps noise low). + +3. **Extend the CLI** (`src/issue_flow/cli.py`) on `init`: + - Add `--skip-dep-check / --no-skip-dep-check` option (default + `False`). Pass through to `run_init`. + - `run_init` signature becomes + `run_init(project_root, force=False, skip_dep_check=False)`. + - `run_update` gets the same treatment so that `issue-flow update` + surfaces missing deps too (same helper, same flag). + +4. **README.md** — add a "Prerequisites" section directly before + "Installation": + - Required: `git`, `gh` (GitHub CLI). + - Recommended: `uv` (already covered by the existing install snippet, + link to https://docs.astral.sh/uv/). + - Short install pointers per OS for `gh` (Homebrew, `winget`, + `apt`, link to https://cli.github.com/). Include + `gh auth login` reminder. + - Note that `issue-flow init` now runs a dependency check and how to + bypass it (`--skip-dep-check`). + +5. **HISTORY.md** — add an `[Unreleased]` bullet describing the new + dependency check and prerequisites docs (follow existing changelog + style). + +## Files to touch + +- `src/issue_flow/dependencies.py` — new module with + `REQUIRED_DEPENDENCIES`, `check_dependencies`, `format_missing_report`. +- `src/issue_flow/init.py` — call the check inside `run_init` and + `run_update`; thread `skip_dep_check` through. +- `src/issue_flow/cli.py` — add `--skip-dep-check` option to `init` + (and `update`, for symmetry). +- `README.md` — new "Prerequisites" section covering `git`, `gh`, `uv` + with install hints, plus mention of the init-time check. +- `HISTORY.md` — `[Unreleased]` entry. +- `tests/test_dependencies.py` — new: unit tests for + `check_dependencies` using `monkeypatch` on `shutil.which`, and for + `format_missing_report` output. +- `tests/test_init.py` — additions: + - check is skipped when all deps present (no prompt, no abort). + - missing dep + `skip_dep_check=True` continues scaffolding. + - missing dep + non-TTY continues scaffolding (simulate via monkeypatch + on `sys.stdin.isatty`). + - missing dep + user declines → `run_init` raises `typer.Exit` and + does *not* create `.cursor/` scaffold files. + +## Test strategy + +- `uv run pytest` — existing suite must keep passing (current tests call + `run_init(tmp_path)` which will now hit the dep check; ensure the + default behavior with real `gh`/`git` on dev machines is still green, + and for CI / machines without `gh`, tests must monkeypatch + `shutil.which` so they never prompt). +- New targeted tests in `tests/test_dependencies.py` and extra cases in + `tests/test_init.py` as listed above. +- Manual smoke: run `uv run issue-flow init /tmp/demo` with `gh` + temporarily off `PATH` to verify the warning + prompt flow, and with + `--skip-dep-check` to verify the bypass. + +## Open questions + +- Should the check also cover `uv` itself? Current plan says **no** (uv + isn't invoked by the scaffolded workflow at runtime — it's only an + install-time prerequisite documented in the README). Confirm. +- Flag name: `--skip-dep-check` vs `--yes` vs `--no-dep-check`. Plan + picks `--skip-dep-check` for clarity; open to the user's preference. +- Should declining the prompt exit with code `1` (plan default) or `0` + (treat as "user chose not to continue")? Plan uses `1` so CI notices. diff --git a/.issueflows/03-solved-issues/issue18_status.md b/.issueflows/03-solved-issues/issue18_status.md new file mode 100644 index 0000000..d3c952a --- /dev/null +++ b/.issueflows/03-solved-issues/issue18_status.md @@ -0,0 +1,59 @@ +# Status for issue #18: better documentation on dependencies + +- [x] Done + +## Implementation summary + +All items from `issue18_plan.md` have landed on branch +`18-better-documentation-on-dependencies`: + +- **New module `src/issue_flow/dependencies.py`.** Declares + `REQUIRED_DEPENDENCIES` (`git`, `gh`) with per-OS install hints and a + docs link each. Exposes `check_dependencies()` (pure `shutil.which`) + and `prompt_or_skip()` (prints the missing-deps report via `rich`, + bypasses the prompt on `skip=True` or non-TTY stdin, otherwise calls + `typer.confirm`). `uv` is deliberately not in the runtime check — it's + only called out in the README as an install-time prerequisite. +- **Wired into `init.py`.** `run_init` and `run_update` now take a new + `skip_dep_check` kwarg and call `_dependency_gate(skip_dep_check)` + before doing any scaffolding. A decline at the prompt raises + `typer.Exit(code=1)` so CI notices. +- **CLI surface (`src/issue_flow/cli.py`).** Both `init` and `update` + gained `--skip-dep-check`. Verified via `uv run issue-flow init --help`. +- **Docs.** + - `README.md`: new **Prerequisites** section above **Installation** + with `git`, `gh`, and `uv` entries, a per-OS install table for `gh`, + and a note about the new check and `--skip-dep-check` bypass. The + `issue-flow init` / `update` option tables and the Usage shell + block were updated to list the flag. + - `HISTORY.md`: new `[Unreleased]` bullet crediting issue #18. +- **Tests.** + - New `tests/conftest.py` with an autouse fixture that stubs + `check_dependencies` to return `[]` by default, so the rest of the + suite is deterministic regardless of whether the host has `git` / + `gh` on `PATH`. + - New `tests/test_dependencies.py` — 11 cases covering the dep list + scope, presence/absence detection, custom dependency lists, report + formatting, and the four `prompt_or_skip` branches (empty, skip, + non-TTY, TTY accept/decline). + - `tests/test_init.py` gained four cases: proceeds silently when all + deps present, continues with `skip_dep_check=True`, continues on + non-TTY stdin, aborts with `typer.Exit(1)` and leaves no scaffold + when the user declines. + +## Verification + +- `uv run pytest` → **64 passed** in 6.77s. +- `uv run ruff check src/ tests/` → **All checks passed**. +- Manual smoke: + - `uv run issue-flow init --help` shows the new `--skip-dep-check` flag. + - Forcing `gh` to be "missing" via `shutil.which` monkeypatch and + calling `run_init(..., skip_dep_check=True)` scaffolds the project + correctly (`Created 20 file(s).`). + - The same missing-dep path without the skip flag prints the full + install-hint report and reaches the `typer.confirm` prompt as + designed. + +## Remaining work + +None — ready for `/issue-close`. diff --git a/HISTORY.md b/HISTORY.md index 5a96f81..39836ae 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,7 @@ than the GitHub release notes they link to. ## [Unreleased] +- **Dependency awareness at install time (#18).** A new `Prerequisites` section in the README documents the external CLI tools the scaffolded workflow shells out to (`git`, `gh` — with install hints per OS and a `gh auth login` reminder), and `issue-flow init` / `issue-flow update` now run a `shutil.which`-based dependency check up front. If anything is missing, the CLI prints the install hints and asks for confirmation before continuing. The prompt is auto-skipped on non-TTY stdin (CI) and can be bypassed explicitly with `--skip-dep-check`. - `issue-flow init` now creates or extends a project `.env` with `ISSUEFLOW_*` hints (#35). - Rename `ISSUEFLOW_CURSOR_DIR` to the more tool-agnostic `ISSUEFLOW_AGENT_DIR` (#36). - `/issue-close` flags unrelated uncommitted changes and reminds about the issue branch after the PR is opened (#37). diff --git a/README.md b/README.md index 505619f..41c3289 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,41 @@ Plus a few off-path commands: The matching **Agent Skills** (under `.cursor/skills/`) carry the same workflows for on-demand use with `/issueflow-iflow`, `/issueflow-issue-init`, `/issueflow-issue-plan`, `/issueflow-issue-start`, `/issueflow-issue-pause`, `/issueflow-issue-close`, `/issueflow-issue-cleanup`, `/issueflow-issue-yolo`, `@issueflow-version-bump` when you need only the bump steps, or `@issueflow-history-update` when you need only the changelog update (see [Cursor Agent Skills](https://cursor.com/docs/context/skills)). +## Prerequisites + +issue-flow itself is a small Python CLI, but the **scaffolded slash commands +it writes into your project shell out to a few external tools**. If they are +missing, the slash commands will fail at runtime — so `issue-flow init` now +checks for them up front and prints install hints before it does anything. + +Required: + +- **[Git](https://git-scm.com/downloads)** — used by every slash command for + branch, fetch, status, commit, and push operations. Almost certainly already + installed if you're here, but the check covers it for completeness. +- **[GitHub CLI (`gh`)](https://cli.github.com/)** — used by `/issue-init` to + fetch issues, by `/issue-close` to open PRs, and by `/issue-cleanup` to check + PR merge status. After installing, run `gh auth login` once to authenticate. + +Recommended: + +- **[uv](https://docs.astral.sh/uv/)** — how issue-flow itself is meant to be + installed, and how this repo manages its own Python environment. + +Quick install pointers for `gh`: + +| Platform | Command | +|---|---| +| macOS (Homebrew) | `brew install gh` | +| Windows (winget) | `winget install --id GitHub.cli -e` | +| Linux (Debian/Ubuntu) | `sudo apt install gh` (or see [cli.github.com](https://cli.github.com/) for the official repo) | + +If a dependency is missing, `issue-flow init` prints the installation hints +and asks whether to continue anyway. You can bypass the prompt in automation +with `issue-flow init --skip-dep-check` (the same flag is available on +`issue-flow update`), and the prompt is also auto-skipped when stdin is not +a TTY (e.g. CI pipelines). + ## Installation Requires Python 3.13+ and [uv](https://docs.astral.sh/uv/). @@ -84,8 +119,8 @@ That's it. Open the project in Cursor and start with `/iflow` (or step through ` ## Usage ``` -issue-flow init [PROJECT_DIR] [--force] -issue-flow update [PROJECT_DIR] +issue-flow init [PROJECT_DIR] [--force] [--skip-dep-check] +issue-flow update [PROJECT_DIR] [--skip-dep-check] ``` ### `issue-flow init` @@ -94,6 +129,7 @@ issue-flow update [PROJECT_DIR] |---|---| | `PROJECT_DIR` | Project root directory. Defaults to `.` (current directory). | | `--force`, `-f` | Overwrite generated Cursor commands, rules, and workflow doc instead of skipping them. | +| `--skip-dep-check` | Skip the external-CLI dependency check (`git`, `gh`) and the confirmation prompt that follows if anything is missing. Useful in automation. | Running `init` again without `--force` is safe: generated scaffold files that already exist are skipped, and **issue markdown under `.issueflows/` is never touched** by `init` or `update`. When the CLI detects an existing scaffold, it reminds you about `update` and `--force`. @@ -102,6 +138,7 @@ Running `init` again without `--force` is safe: generated scaffold files that al | Argument / Option | Description | |---|---| | `PROJECT_DIR` | Project root directory. Defaults to `.` (current directory). | +| `--skip-dep-check` | Skip the external-CLI dependency check (`git`, `gh`) and the confirmation prompt that follows if anything is missing. | Use `update` after upgrading the **issue-flow** package to refresh the packaged slash commands, Cursor rule, and `docs/cursor-issue-workflow.md` from the version you have installed. This **overwrites** those generated files (unlike a plain second `init`). It still does not modify arbitrary files under `.issueflows/` (for example your `issue*_original.md` / `issue*_status.md` files), and it creates any **new** `.issueflows/` subdirectories required by the current package. diff --git a/src/issue_flow/cli.py b/src/issue_flow/cli.py index 4ba24e0..f1984e7 100644 --- a/src/issue_flow/cli.py +++ b/src/issue_flow/cli.py @@ -32,11 +32,21 @@ def init( "-f", help="Overwrite existing files without asking.", ), + skip_dep_check: bool = typer.Option( + False, + "--skip-dep-check", + help=( + "Skip the external-CLI dependency check (git, gh) and the " + "confirmation prompt that follows if anything is missing." + ), + ), ) -> None: """Scaffold issue-flow directories and Cursor config files in a project.""" from issue_flow.init import run_init - run_init(project_root=project_dir, force=force) + run_init( + project_root=project_dir, force=force, skip_dep_check=skip_dep_check + ) @app.command() @@ -48,11 +58,19 @@ def update( file_okay=False, resolve_path=True, ), + skip_dep_check: bool = typer.Option( + False, + "--skip-dep-check", + help=( + "Skip the external-CLI dependency check (git, gh) and the " + "confirmation prompt that follows if anything is missing." + ), + ), ) -> None: """Refresh packaged Cursor commands, rules, and workflow doc from this package.""" from issue_flow.init import run_update - run_update(project_root=project_dir) + run_update(project_root=project_dir, skip_dep_check=skip_dep_check) def main() -> None: diff --git a/src/issue_flow/dependencies.py b/src/issue_flow/dependencies.py new file mode 100644 index 0000000..ddd1097 --- /dev/null +++ b/src/issue_flow/dependencies.py @@ -0,0 +1,162 @@ +"""External CLI dependency detection for issue-flow. + +The scaffolded workflow shells out to ``git`` and ``gh`` (GitHub CLI) via +slash commands. We detect missing tools at ``issue-flow init`` / +``issue-flow update`` time so users get a clear install hint rather than a +confusing failure later from inside a Cursor command. +""" + +from __future__ import annotations + +import shutil +import sys +from dataclasses import dataclass + +from rich.console import Console + + +@dataclass(frozen=True) +class Dependency: + """A single external CLI tool issue-flow's workflow depends on.""" + + name: str + command: str + purpose: str + docs_url: str + # Platform hint → short install snippet. Keys are free-form labels + # shown verbatim (e.g. "macOS (Homebrew)", "Windows (winget)", "Linux + # (Debian/Ubuntu)"). Values are one-line commands. + install_hints: tuple[tuple[str, str], ...] + + +# The scaffolded slash commands (see +# ``src/issue_flow/templates/commands/*.md.j2``) invoke these tools. ``uv`` +# is intentionally *not* listed here: it is an install-time prerequisite +# for issue-flow itself, not something the scaffold calls at runtime, so +# it belongs in the README only. +REQUIRED_DEPENDENCIES: tuple[Dependency, ...] = ( + Dependency( + name="Git", + command="git", + purpose=( + "Used by every slash command for branch, fetch, status, " + "commit, and push operations." + ), + docs_url="https://git-scm.com/downloads", + install_hints=( + ("macOS (Homebrew)", "brew install git"), + ("Windows (winget)", "winget install --id Git.Git -e"), + ("Linux (Debian/Ubuntu)", "sudo apt install git"), + ), + ), + Dependency( + name="GitHub CLI", + command="gh", + purpose=( + "Used by /issue-init to fetch issues, /issue-close to open " + "PRs, and /issue-cleanup to check PR merge status. Remember " + "to run `gh auth login` once after installing." + ), + docs_url="https://cli.github.com/", + install_hints=( + ("macOS (Homebrew)", "brew install gh"), + ("Windows (winget)", "winget install --id GitHub.cli -e"), + ( + "Linux (Debian/Ubuntu)", + "sudo apt install gh # or see https://cli.github.com/ for the official repo", + ), + ), + ), +) + + +def check_dependencies( + dependencies: tuple[Dependency, ...] = REQUIRED_DEPENDENCIES, +) -> list[Dependency]: + """Return the subset of ``dependencies`` whose ``command`` is not on ``PATH``. + + Uses :func:`shutil.which` only — no subprocess invocations — so it is + safe to call on any platform without risk of hanging or prompting. + """ + return [dep for dep in dependencies if shutil.which(dep.command) is None] + + +def format_missing_report( + missing: list[Dependency], + console: Console, +) -> None: + """Print a human-readable report listing missing dependencies. + + The output is intentionally compact and uses only ``rich`` markup so + it blends with the rest of the ``init`` output. + """ + if not missing: + return + + count = len(missing) + noun = "dependency" if count == 1 else "dependencies" + console.print( + f"[bold yellow]Missing {count} external {noun}:[/bold yellow]" + ) + for dep in missing: + console.print( + f"\n [bold]{dep.name}[/bold] " + f"([cyan]{dep.command}[/cyan] not found on PATH)" + ) + console.print(f" [dim]{dep.purpose}[/dim]") + console.print(f" Docs: [blue]{dep.docs_url}[/blue]") + console.print(" Install:") + for label, snippet in dep.install_hints: + console.print(f" - {label}: [green]{snippet}[/green]") + console.print() + + +def prompt_or_skip( + missing: list[Dependency], + console: Console, + *, + skip: bool, + stdin_is_tty: bool | None = None, +) -> bool: + """Decide whether to proceed despite missing deps. + + Returns ``True`` to continue, ``False`` if the user declined. + + - If ``missing`` is empty, returns ``True``. + - If ``skip`` is ``True``, prints a one-line note and returns ``True`` + without prompting. + - If stdin is not a TTY (e.g. CI, piped input), prints a one-line + note and returns ``True`` without prompting so automation doesn't + hang. + - Otherwise asks the user with :func:`typer.confirm`. + """ + if not missing: + return True + + format_missing_report(missing, console) + + if skip: + console.print( + "[dim]Dependency check bypassed via --skip-dep-check; " + "continuing anyway.[/dim]\n" + ) + return True + + if stdin_is_tty is None: + stdin_is_tty = sys.stdin.isatty() + + if not stdin_is_tty: + console.print( + "[dim]Non-interactive session (stdin is not a TTY); " + "continuing without prompting. " + "Install the tools above before running the slash commands.[/dim]\n" + ) + return True + + # Imported lazily to keep this module importable in environments + # that only need check_dependencies (e.g. tests or custom scripts). + import typer + + return typer.confirm( + "Continue with issue-flow setup anyway?", default=False + ) diff --git a/src/issue_flow/init.py b/src/issue_flow/init.py index c2f5f28..1719bf1 100644 --- a/src/issue_flow/init.py +++ b/src/issue_flow/init.py @@ -5,9 +5,14 @@ import re from pathlib import Path +import typer from rich.console import Console from issue_flow.config import Settings +from issue_flow.dependencies import ( + check_dependencies, + prompt_or_skip, +) from issue_flow.templating import ( TEMPLATE_MANIFEST, render_template, @@ -126,7 +131,11 @@ def _already_initialized( ) -def run_init(project_root: Path, force: bool = False) -> None: +def run_init( + project_root: Path, + force: bool = False, + skip_dep_check: bool = False, +) -> None: """Scaffold .issueflows/ directories and .cursor/ config (commands, rules, skills). Also ensures a project-root ``.env`` exists or appends commented @@ -137,9 +146,15 @@ def run_init(project_root: Path, force: bool = False) -> None: edits and issue markdown under ``.issueflows/`` are preserved. Manifest paths never include issue status or description files. + Before scaffolding, checks for required external CLI tools (``git``, + ``gh``). If any are missing, prints install guidance and asks for + confirmation before continuing (unless ``skip_dep_check`` is set or + stdin is non-interactive). + Args: project_root: Absolute path to the user's project directory. force: If True, overwrite existing manifest files without asking. + skip_dep_check: If True, bypass the external-CLI dependency check. """ settings = Settings() context = settings.template_context(project_root) @@ -148,6 +163,9 @@ def run_init(project_root: Path, force: bool = False) -> None: f"\n[bold]Initializing issue-flow in [cyan]{project_root}[/cyan][/bold]\n" ) + if not _dependency_gate(skip_dep_check): + raise typer.Exit(code=1) + if not force and _already_initialized(project_root, settings, context): console.print( "[dim]This project already has issue-flow scaffold files. " @@ -185,7 +203,7 @@ def run_init(project_root: Path, force: bool = False) -> None: ) -def run_update(project_root: Path) -> None: +def run_update(project_root: Path, skip_dep_check: bool = False) -> None: """Refresh packaged scaffold files (commands, rule, skills, workflow doc). Overwrites every path in ``TEMPLATE_MANIFEST`` with the templates from the @@ -194,6 +212,13 @@ def run_update(project_root: Path) -> None: Ensures ``.issueflows/`` subdirectories from settings exist (e.g. new folders in a newer package version). + + Runs the same external-CLI dependency check as :func:`run_init` so + upgrades also surface missing tools. + + Args: + project_root: Absolute path to the user's project directory. + skip_dep_check: If True, bypass the external-CLI dependency check. """ settings = Settings() context = settings.template_context(project_root) @@ -202,6 +227,9 @@ def run_update(project_root: Path) -> None: f"\n[bold]Updating issue-flow scaffold in [cyan]{project_root}[/cyan][/bold]\n" ) + if not _dependency_gate(skip_dep_check): + raise typer.Exit(code=1) + _create_issueflow_dirs(project_root, settings) written_files, _skipped = _write_manifest_files(project_root, context, force=True) @@ -220,6 +248,17 @@ def run_update(project_root: Path) -> None: ) +def _dependency_gate(skip_dep_check: bool) -> bool: + """Run the external-CLI dependency check and decide whether to proceed. + + Returns True if ``run_init`` / ``run_update`` should continue, False if + the user declined the confirmation prompt after seeing a missing-deps + report. + """ + missing = check_dependencies() + return prompt_or_skip(missing, console, skip=skip_dep_check) + + def _create_issueflow_dirs(project_root: Path, settings: Settings) -> None: """Create the .issueflows/ directory tree.""" base = project_root / settings.issueflows_dir diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1136655 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,26 @@ +"""Shared pytest fixtures for issue-flow tests.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _stub_dependency_check(monkeypatch: pytest.MonkeyPatch) -> None: + """Make ``check_dependencies`` a no-op by default. + + The production dep-check uses :func:`shutil.which` and would otherwise + behave differently between dev machines (where ``git`` / ``gh`` are + usually present) and CI (where they may not be). Stubbing it out + keeps unrelated tests deterministic and prevents any ``typer.confirm`` + prompt from being triggered during ``run_init`` / ``run_update``. + + Tests that want to exercise the real dependency logic import and call + :func:`issue_flow.dependencies.check_dependencies` (or + ``_dependency_gate``) directly after re-patching ``shutil.which`` — + see ``tests/test_dependencies.py`` and the dep-focused cases in + ``tests/test_init.py``. + """ + from issue_flow import init as init_module + + monkeypatch.setattr(init_module, "check_dependencies", lambda: []) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..bb5f112 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,154 @@ +"""Tests for issue_flow.dependencies (external CLI dependency check).""" + +from __future__ import annotations + +from io import StringIO + +import pytest +from rich.console import Console + +from issue_flow import dependencies as deps_module +from issue_flow.dependencies import ( + REQUIRED_DEPENDENCIES, + Dependency, + check_dependencies, + format_missing_report, + prompt_or_skip, +) + + +def _fake_console() -> tuple[Console, StringIO]: + """A Console whose output is captured to a StringIO for assertion.""" + buffer = StringIO() + return Console(file=buffer, width=120, force_terminal=False), buffer + + +def test_required_dependencies_are_git_and_gh() -> None: + """The plan explicitly scopes the check to git + gh (no uv).""" + commands = {dep.command for dep in REQUIRED_DEPENDENCIES} + assert commands == {"git", "gh"} + + +def test_check_dependencies_returns_empty_when_all_present(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(deps_module.shutil, "which", lambda _cmd: "/usr/bin/fake") + + assert check_dependencies() == [] + + +def test_check_dependencies_returns_missing(monkeypatch: pytest.MonkeyPatch) -> None: + def which(cmd: str) -> str | None: + return None if cmd == "gh" else "/usr/bin/fake" + + monkeypatch.setattr(deps_module.shutil, "which", which) + + missing = check_dependencies() + + assert [dep.command for dep in missing] == ["gh"] + + +def test_check_dependencies_accepts_custom_dependency_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Caller-supplied dependency list is honored (useful for tests).""" + monkeypatch.setattr(deps_module.shutil, "which", lambda _cmd: None) + custom = ( + Dependency( + name="Fake", + command="fake-tool", + purpose="test", + docs_url="https://example.invalid", + install_hints=(("any", "install fake-tool"),), + ), + ) + + missing = check_dependencies(custom) + + assert [dep.command for dep in missing] == ["fake-tool"] + + +def test_format_missing_report_mentions_name_command_and_install_hints() -> None: + console, buffer = _fake_console() + dep = REQUIRED_DEPENDENCIES[-1] # gh + + format_missing_report([dep], console) + + output = buffer.getvalue() + assert dep.name in output + assert dep.command in output + assert dep.docs_url in output + for label, snippet in dep.install_hints: + assert label in output + assert snippet in output + + +def test_format_missing_report_is_silent_when_nothing_missing() -> None: + console, buffer = _fake_console() + + format_missing_report([], console) + + assert buffer.getvalue() == "" + + +def test_prompt_or_skip_returns_true_when_nothing_missing() -> None: + console, buffer = _fake_console() + + assert prompt_or_skip([], console, skip=False, stdin_is_tty=True) is True + assert buffer.getvalue() == "" + + +def test_prompt_or_skip_bypasses_prompt_when_skip_flag_set() -> None: + console, buffer = _fake_console() + dep = REQUIRED_DEPENDENCIES[0] + + result = prompt_or_skip([dep], console, skip=True, stdin_is_tty=True) + + assert result is True + assert "bypassed" in buffer.getvalue() + + +def test_prompt_or_skip_bypasses_prompt_on_non_tty() -> None: + console, buffer = _fake_console() + dep = REQUIRED_DEPENDENCIES[0] + + result = prompt_or_skip([dep], console, skip=False, stdin_is_tty=False) + + assert result is True + assert "Non-interactive" in buffer.getvalue() + + +def test_prompt_or_skip_calls_typer_confirm_on_tty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When stdin is a TTY and user declines, prompt_or_skip returns False.""" + console, _ = _fake_console() + dep = REQUIRED_DEPENDENCIES[0] + + import typer + + calls: list[str] = [] + + def fake_confirm(prompt: str, default: bool = False) -> bool: # noqa: ARG001 + calls.append(prompt) + return False + + monkeypatch.setattr(typer, "confirm", fake_confirm) + + result = prompt_or_skip([dep], console, skip=False, stdin_is_tty=True) + + assert result is False + assert len(calls) == 1 + assert "Continue" in calls[0] + + +def test_prompt_or_skip_accepts_tty_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When the user confirms at the prompt, proceed.""" + console, _ = _fake_console() + dep = REQUIRED_DEPENDENCIES[0] + + import typer + + monkeypatch.setattr(typer, "confirm", lambda *_a, **_kw: True) + + assert prompt_or_skip([dep], console, skip=False, stdin_is_tty=True) is True diff --git a/tests/test_init.py b/tests/test_init.py index 62b1020..c0dde46 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,6 +4,12 @@ from pathlib import Path +import pytest +import typer + +from issue_flow import dependencies as deps_module +from issue_flow import init as init_module +from issue_flow.dependencies import REQUIRED_DEPENDENCIES from issue_flow.init import run_init @@ -222,6 +228,77 @@ def test_init_issue_init_documents_branch_inference(tmp_path: Path) -> None: assert "issue-style branch" in content +def test_init_proceeds_silently_when_all_dependencies_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """With all deps present the check should not prompt or abort.""" + monkeypatch.setattr(init_module, "check_dependencies", lambda: list(REQUIRED_DEPENDENCIES[:0])) + + def fail_confirm(*_a: object, **_kw: object) -> bool: + raise AssertionError("typer.confirm should not be called when all deps present") + + monkeypatch.setattr(typer, "confirm", fail_confirm) + + run_init(tmp_path) + + assert (tmp_path / ".cursor" / "commands" / "issue-init.md").is_file() + + +def test_init_continues_when_skip_dep_check_is_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """``skip_dep_check=True`` must bypass the prompt even if deps are missing.""" + monkeypatch.setattr( + init_module, "check_dependencies", lambda: list(REQUIRED_DEPENDENCIES) + ) + + def fail_confirm(*_a: object, **_kw: object) -> bool: + raise AssertionError("typer.confirm must not run when --skip-dep-check is set") + + monkeypatch.setattr(typer, "confirm", fail_confirm) + + run_init(tmp_path, skip_dep_check=True) + + assert (tmp_path / ".cursor" / "commands" / "issue-init.md").is_file() + + +def test_init_continues_in_non_tty_when_deps_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-interactive stdin (CI) must auto-skip the prompt.""" + monkeypatch.setattr( + init_module, "check_dependencies", lambda: list(REQUIRED_DEPENDENCIES) + ) + monkeypatch.setattr(deps_module.sys.stdin, "isatty", lambda: False) + + def fail_confirm(*_a: object, **_kw: object) -> bool: + raise AssertionError("typer.confirm must not run on non-TTY stdin") + + monkeypatch.setattr(typer, "confirm", fail_confirm) + + run_init(tmp_path) + + assert (tmp_path / ".cursor" / "commands" / "issue-init.md").is_file() + + +def test_init_aborts_cleanly_when_user_declines_prompt( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A decline at the prompt must raise typer.Exit and leave no scaffold behind.""" + monkeypatch.setattr( + init_module, "check_dependencies", lambda: list(REQUIRED_DEPENDENCIES) + ) + monkeypatch.setattr(deps_module.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(typer, "confirm", lambda *_a, **_kw: False) + + with pytest.raises(typer.Exit) as exc_info: + run_init(tmp_path) + + assert exc_info.value.exit_code == 1 + assert not (tmp_path / ".cursor").exists() + assert not (tmp_path / ".issueflows").exists() + + def test_init_detects_project_name(tmp_path: Path) -> None: """If a pyproject.toml exists, its name should appear in the rule file.""" pyproject = tmp_path / "pyproject.toml"