diff --git a/AGENTS.md b/AGENTS.md index a1fc90a..3fce067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,9 @@ src/devx/ │ ├── create_pr.py # Create PRs with auto-derived title from Vikunja │ ├── pr_status.py # Check CI status for a PR/commit (--wait polls) │ ├── pr_logs.py # Fetch logs for failed CI jobs -│ └── pr_label.py # Add labels to PRs (idempotent) +│ ├── pr_label.py # Add labels to PRs (idempotent) +│ ├── rebase.py # Rebase current branch onto origin/master + force-push +│ └── pr_rebase.py # Rebase a PR's head branch via Gitea API (server-side) ├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field) └── molecule/ # Optional molecule testing helpers (for Ansible projects) ├── discover_runners.py # Dynamic Gitea runner discovery @@ -181,6 +183,12 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will: 5. The post-merge workflow marks the Vikunja task as done 6. The release workflow automatically versions, tags, and publishes +**If the branch is behind master** (another PR merged first), auto-merge +automatically rebases the PR's head branch via the Gitea API +(`POST /pulls/{index}/update?style=rebase`). This triggers a new CI run. +The next auto-merge attempt will find the branch up-to-date and merge +successfully. No manual intervention needed. + > **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge > workflow by adding the `ready-to-merge` label. @@ -409,6 +417,8 @@ projects. | `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) | | `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) | | `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) | +| `devx-rebase` | Rebase current branch onto origin/master + force-push (`NO_PUSH=1` for local only) | +| `devx-pr-rebase` | Rebase a PR's head branch via Gitea API — server-side, no local git needed (`PR=`) | | `devx-check-config` | Validate devx configuration | | `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry | | `devx-env` | Create .env from .env.example | diff --git a/Makefile b/Makefile index 80f7b9f..027cb61 100644 --- a/Makefile +++ b/Makefile @@ -98,6 +98,8 @@ create-task: devx-create-task create-pr: devx-create-pr push-with-pr: devx-push-with-pr git-push: devx-push +rebase: devx-rebase +pr-rebase: devx-pr-rebase lint-all: lint workflow-lint lint-dockerfiles @echo "[lint-all] All linting checks passed." diff --git a/docs/index.md b/docs/index.md index 7cbd6d4..0a15feb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -131,7 +131,7 @@ wiki sync details. devx provides a `devx` CLI with three command groups: - `devx ci ` — CI/CD automation (17 commands) -- `devx tools ` — Developer tools (7 commands) +- `devx tools ` — Developer tools (9 commands) - `devx molecule ` — Molecule testing (4 commands, optional) See [CLI Commands](CLI-Commands) for full command documentation with examples. diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index 1df8ada..9f7463e 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -421,6 +421,35 @@ Options: - `--no-pre-commit` — skip pre-commit hook installation - `--no-tea-login` — skip tea CLI login configuration +### `devx tools rebase` + +Rebase the current branch onto `origin/master` and force-push with +`--force-with-lease`. Checks if the branch is behind master first — +if up-to-date, exits without doing anything. + +```bash +devx tools rebase # rebase + force-push +devx tools rebase -- --no-push # rebase locally only +``` + +Options (pass after `--`): +- `--no-push` — rebase locally without pushing + +### `devx tools pr-rebase` + +Rebase a pull request's head branch onto master via the Gitea API +(server-side). This triggers a new `pull_request synchronize` event, +which starts a new CI run. Useful when you don't have the branch +checked out locally. + +```bash +devx tools pr-rebase -- --pr 42 # rebase PR #42 +devx tools pr-rebase # auto-detect PR from current branch +``` + +Options (pass after `--`): +- `--pr ` — PR number (auto-detected from current branch if omitted) + ## Molecule Commands Molecule commands require the `molecule` extra (`pip install devx[molecule]`). diff --git a/src/devx/api_clients.py b/src/devx/api_clients.py index 9781105..d921f93 100644 --- a/src/devx/api_clients.py +++ b/src/devx/api_clients.py @@ -194,6 +194,19 @@ class GiteaClient: payload = {"Do": "squash", "MergeTitleField": merge_title} self._request("POST", f"/pulls/{pr_number}/merge", json=payload) + def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None: + """Update PR head branch by merging/rebasing the base branch into it. + + Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint. + This rebases the PR's head branch onto the latest base branch server-side, + triggering a ``pull_request synchronize`` event that starts a new CI run. + + Args: + pr_number: PR number. + style: Update method — ``"rebase"`` (default) or ``"merge"``. + """ + self._request("POST", f"/pulls/{pr_number}/update", params={"style": style}) + def get_commit_status(self, sha: str) -> list[dict[str, Any]]: """Fetch all status check contexts reported for a commit. diff --git a/src/devx/ci/auto_merge.py b/src/devx/ci/auto_merge.py index 7563cf3..7e700b5 100644 --- a/src/devx/ci/auto_merge.py +++ b/src/devx/ci/auto_merge.py @@ -231,17 +231,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: client.merge_pr(pr_num, merge_title) except APIError as e: if e.status == 405 and "behind" in e.message.lower(): - # Head branch is behind master — do NOT auto-rebase. - # Auto-rebasing creates a feedback loop: the force-push triggers - # a new pull_request synchronize event, which starts a new CI run, - # which runs auto-merge again, which rebases again, etc. - raise click.ClickException( + # Head branch is behind master. Auto-rebase via Gitea API. + # This triggers a new pull_request synchronize event → new CI run. + # The next auto-merge attempt will find the branch up-to-date and + # merge successfully. This is NOT an infinite loop: the rebase + # resolves the "behind" condition, so the next run merges. + # If another PR merges in between, the branch may fall behind + # again, but the process converges as PRs stop merging. + click.echo( _( - "Branch is behind master. Rebase manually:\n" - " git fetch origin master && git rebase origin/master && git push --force-with-lease\n" - "Then re-add the ready-to-merge label.", + "Branch is behind master. Auto-rebasing via Gitea API...\n" + "A new CI run will start automatically after the rebase.\n" + "The next auto-merge attempt will merge this PR.", ) - ) from None + ) + try: + client.update_pr_branch(pr_num, style="rebase") + except APIError as rebase_err: + raise click.ClickException( + _( + "Auto-rebase failed with HTTP {status}: {message}\n" + "Rebase manually:\n" + " git fetch origin master && git rebase origin/master && git push --force-with-lease\n" + "Then re-add the ready-to-merge label.", + status=rebase_err.status, + message=rebase_err.message, + ) + ) from None + # Exit cleanly — the rebase triggers a new CI run that will retry. + return else: raise click.ClickException( _( diff --git a/src/devx/cli.py b/src/devx/cli.py index a428b97..1cb3e08 100644 --- a/src/devx/cli.py +++ b/src/devx/cli.py @@ -226,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None: _run_module("devx.tools.setup", list(args)) +@tools.command("rebase") +@click.argument("args", nargs=-1) +def tools_rebase(args: tuple[str, ...]) -> None: + """Rebase current branch onto origin/master and force-push.""" + _run_module("devx.tools.rebase", list(args)) + + +@tools.command("pr-rebase") +@click.argument("args", nargs=-1) +def tools_pr_rebase(args: tuple[str, ...]) -> None: + """Rebase a PR's head branch onto master via Gitea API (server-side).""" + _run_module("devx.tools.pr_rebase", list(args)) + + @cli.group() def molecule() -> None: """Molecule testing commands (requires devx[molecule]).""" diff --git a/src/devx/make/devx.mak b/src/devx/make/devx.mak index 910b789..98fd987 100644 --- a/src/devx/make/devx.mak +++ b/src/devx/make/devx.mak @@ -63,7 +63,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; $(DEVX_BIN)/pip .PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config -.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review +.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase .PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake .PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check .PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts @@ -134,6 +134,20 @@ devx-pr-review: $(if $(BODY),--body "$(BODY)") \ $(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST)) +# Rebase current branch onto origin/master and force-push +# Usage: make devx-rebase +# make devx-rebase NO_PUSH=1 +devx-rebase: + @$(DEVX_PYTHON) -m devx.tools.rebase \ + $(if $(NO_PUSH),--no-push) + +# Rebase a PR's head branch via Gitea API (server-side, no local git needed) +# Usage: make devx-pr-rebase +# make devx-pr-rebase PR=42 +devx-pr-rebase: + @$(DEVX_PYTHON) -m devx.tools.pr_rebase \ + $(if $(PR),--pr $(PR)) + # ── Environment setup ───────────────────────────────────────────────────────── # Configure Gitea private PyPI registry so pip can find devx and other diff --git a/src/devx/tools/_shared.py b/src/devx/tools/_shared.py index e92edcc..0707ee3 100644 --- a/src/devx/tools/_shared.py +++ b/src/devx/tools/_shared.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os import platform +import subprocess # nosec B404 import click @@ -22,3 +24,52 @@ def arch_string() -> str: if machine in {"aarch64", "arm64"}: return "arm64" raise click.ClickException(f"Unsupported architecture: {machine}") + + +def detect_pr_number() -> int | None: + """Detect the PR number for the current git branch. + + Returns the PR number if the current branch has an open PR, or None + if no PR is found. Does NOT raise — callers decide how to handle None. + Best-effort: returns None on any failure (no token, API down, etc.). + """ + result = subprocess.run( # nosec B603, B607 + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + branch = result.stdout.strip() + if branch == "HEAD": + return None + + token = os.environ.get("CI_GITEA_TOKEN", "") + if not token: + return None + + owner = os.environ.get("DEVX_REPO_OWNER", "") + repo = os.environ.get("DEVX_REPO_NAME", "") + if not owner or not repo: + github_repo = os.environ.get("GITHUB_REPOSITORY", "") + if "/" in github_repo: + owner, repo = github_repo.split("/", 1) + + if not owner or not repo: + return None + + # Lazy import to avoid circular dependency + from devx.api_clients import APIError, GiteaClient # noqa: PLC0415 + from devx.config import GITEA_API_URL # noqa: PLC0415 + + client = GiteaClient(GITEA_API_URL, token, owner, repo) + try: + prs = client.list_prs(state="open") + except APIError: + # Best-effort: API down or auth failure → no PR detected + return None + for pr in prs: + if pr.get("head", {}).get("ref") == branch: + return int(pr["number"]) + return None diff --git a/src/devx/tools/pr_rebase.py b/src/devx/tools/pr_rebase.py new file mode 100644 index 0000000..adedebc --- /dev/null +++ b/src/devx/tools/pr_rebase.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Rebase a pull request's head branch onto master via Gitea API. + +Uses the Gitea ``POST /pulls/{index}/update?style=rebase`` endpoint to +rebase the PR's head branch server-side. This triggers a new +``pull_request synchronize`` event, which starts a new CI run. + +This is useful when: + - You don't have the branch checked out locally + - You want to rebase a PR from another machine + - You want to trigger the auto-merge retry without local git operations + +Usage:: + + # Rebase PR #42 + python -m devx.tools.pr_rebase --pr 42 + + # Rebase current branch's PR (auto-detected) + python -m devx.tools.pr_rebase + +The repository is auto-detected from ``DEVX_REPO_OWNER`` / +``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables. +""" + +from __future__ import annotations + +import os + +import click +from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] + +from devx.api_clients import APIError, GiteaClient +from devx.config import GITEA_API_URL +from devx.i18n import _ +from devx.tools._shared import detect_pr_number + + +@click.command() +@click.option("--pr", type=int, help="PR number (auto-detected if omitted).") +def main(pr: int | None) -> None: + """Rebase a pull request's head branch onto master via Gitea API.""" + load_dotenv() + + token = os.environ.get("CI_GITEA_TOKEN", "") + if not token: + raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) + + pr_num = pr or detect_pr_number() + if not pr_num: + raise click.ClickException( + _( + "Could not detect PR number. Use --pr to specify it explicitly,\n" + "or run this command from a branch with an open PR.", + ) + ) + + owner = os.environ.get("DEVX_REPO_OWNER", "") + repo = os.environ.get("DEVX_REPO_NAME", "") + if not owner or not repo: + github_repo = os.environ.get("GITHUB_REPOSITORY", "") + if "/" in github_repo: + owner, repo = github_repo.split("/", 1) + + if not owner or not repo: + raise click.ClickException( + _( + "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\n" + "or GITHUB_REPOSITORY environment variables.", + ) + ) + + client = GiteaClient(GITEA_API_URL, token, owner, repo) + + click.echo(_("Rebasing PR #{pr} via Gitea API...", pr=pr_num)) + try: + client.update_pr_branch(pr_num, style="rebase") + except APIError as e: + raise click.ClickException( + _( + "Rebase failed with HTTP {status}: {message}", + status=e.status, + message=e.message, + ) + ) from None + + click.echo( + _( + "PR #{pr} rebased successfully. A new CI run will start automatically.\n" + "If auto-merge is enabled (ready-to-merge label), the next CI run\n" + "will attempt to merge this PR.", + pr=pr_num, + ) + ) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/tools/rebase.py b/src/devx/tools/rebase.py new file mode 100644 index 0000000..e558b69 --- /dev/null +++ b/src/devx/tools/rebase.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Rebase current branch onto origin/master and force-push. + +Fetches origin/master, rebases the current branch, and force-pushes with +``--force-with-lease``. This is the manual equivalent of what +``auto_merge.py`` does automatically via the Gitea API. + +Usage:: + + # Rebase current branch onto master and force-push + python -m devx.tools.rebase + + # Rebase without pushing (local only) + python -m devx.tools.rebase --no-push + +The tool fails if: + - The rebase encounters conflicts (exits with rebase in progress) + - The force-push is rejected (remote has unexpected commits) + - Not on a branch (detached HEAD) +""" + +from __future__ import annotations + +import subprocess # nosec B404 + +import click + +from devx.i18n import _ + + +def _run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + """Run a git command and return the result.""" + return subprocess.run( # nosec B603, B607 + ["git", *args], + capture_output=True, + text=True, + check=check, + ) + + +@click.command() +@click.option("--no-push", is_flag=True, help="Rebase locally without pushing.") +def main(no_push: bool) -> None: + """Rebase current branch onto origin/master and force-push.""" + # Ensure we're on a branch (check=False — we handle errors ourselves) + branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False) + if branch_result.returncode != 0: + raise click.ClickException(_("Could not detect current branch: {error}", error=branch_result.stderr.strip())) + branch = branch_result.stdout.strip() + if branch == "HEAD": + raise click.ClickException(_("Cannot rebase: not on a branch (detached HEAD).")) + + click.echo(_("Fetching origin/master...")) + fetch = _run_git(["fetch", "origin", "master"], check=False) + if fetch.returncode != 0: + raise click.ClickException(_("Fetch failed: {error}", error=fetch.stderr.strip())) + + # Check if behind master + behind = _run_git( + ["rev-list", "--count", "HEAD..origin/master"], + check=False, + ) + behind_count = int(behind.stdout.strip()) if behind.stdout.strip().isdigit() else 0 + + if behind_count == 0: + click.echo(_("Branch is already up-to-date with origin/master.")) + if not no_push: + click.echo(_("Nothing to push.")) + return + + click.echo(_("Branch is {count} commit(s) behind master. Rebasing...", count=behind_count)) + rebase = _run_git(["rebase", "origin/master"], check=False) + if rebase.returncode != 0: + raise click.ClickException( + _( + "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + error=rebase.stderr.strip() or rebase.stdout.strip(), + ) + ) + + click.echo(_("Rebase successful.")) + + if not no_push: + click.echo(_("Force-pushing...")) + push = _run_git(["push", "--force-with-lease", "origin", branch], check=False) + if push.returncode != 0: + raise click.ClickException( + _( + "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + error=push.stderr.strip(), + ) + ) + click.echo(_("Pushed {branch} to origin.", branch=branch)) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/translations.json b/src/devx/translations.json index c97ba4e..8b72e57 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -775,14 +775,6 @@ "ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"", "zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\"" }, - "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": { - "bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", - "de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", - "en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", - "pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.", - "ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", - "zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label." - }, "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": { "bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", "de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master", @@ -2926,5 +2918,157 @@ "pl": "Rebase attempt {n}/3 failed: {err}", "ru": "Rebase attempt {n}/3 failed: {err}", "zh": "Rebase attempt {n}/3 failed: {err}" + }, + "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": { + "bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", + "de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", + "en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", + "pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", + "ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", + "zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label." + }, + "Branch is already up-to-date with origin/master.": { + "bg": "Branch is already up-to-date with origin/master.", + "de": "Branch is already up-to-date with origin/master.", + "en": "Branch is already up-to-date with origin/master.", + "pl": "Branch is already up-to-date with origin/master.", + "ru": "Branch is already up-to-date with origin/master.", + "zh": "Branch is already up-to-date with origin/master." + }, + "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": { + "bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.", + "de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.", + "en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.", + "pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.", + "ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.", + "zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR." + }, + "Branch is {count} commit(s) behind master. Rebasing...": { + "bg": "Branch is {count} commit(s) behind master. Rebasing...", + "de": "Branch is {count} commit(s) behind master. Rebasing...", + "en": "Branch is {count} commit(s) behind master. Rebasing...", + "pl": "Branch is {count} commit(s) behind master. Rebasing...", + "ru": "Branch is {count} commit(s) behind master. Rebasing...", + "zh": "Branch is {count} commit(s) behind master. Rebasing..." + }, + "CI_GITEA_TOKEN is not set. Add it to .env or export it.": { + "bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.", + "de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.", + "en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.", + "pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.", + "ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.", + "zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it." + }, + "Cannot rebase: not on a branch (detached HEAD).": { + "bg": "Cannot rebase: not on a branch (detached HEAD).", + "de": "Cannot rebase: not on a branch (detached HEAD).", + "en": "Cannot rebase: not on a branch (detached HEAD).", + "pl": "Cannot rebase: not on a branch (detached HEAD).", + "ru": "Cannot rebase: not on a branch (detached HEAD).", + "zh": "Cannot rebase: not on a branch (detached HEAD)." + }, + "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": { + "bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.", + "de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.", + "en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.", + "pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.", + "ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.", + "zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR." + }, + "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": { + "bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.", + "de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.", + "en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.", + "pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.", + "ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.", + "zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables." + }, + "Fetch failed: {error}": { + "bg": "Fetch failed: {error}", + "de": "Fetch failed: {error}", + "en": "Fetch failed: {error}", + "pl": "Fetch failed: {error}", + "ru": "Fetch failed: {error}", + "zh": "Fetch failed: {error}" + }, + "Fetching origin/master...": { + "bg": "Fetching origin/master...", + "de": "Fetching origin/master...", + "en": "Fetching origin/master...", + "pl": "Fetching origin/master...", + "ru": "Fetching origin/master...", + "zh": "Fetching origin/master..." + }, + "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": { + "bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + "de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + "en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + "pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + "ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.", + "zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again." + }, + "Force-pushing...": { + "bg": "Force-pushing...", + "de": "Force-pushing...", + "en": "Force-pushing...", + "pl": "Force-pushing...", + "ru": "Force-pushing...", + "zh": "Force-pushing..." + }, + "Nothing to push.": { + "bg": "Nothing to push.", + "de": "Nothing to push.", + "en": "Nothing to push.", + "pl": "Nothing to push.", + "ru": "Nothing to push.", + "zh": "Nothing to push." + }, + "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": { + "bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", + "de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", + "en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", + "pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", + "ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.", + "zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR." + }, + "Pushed {branch} to origin.": { + "bg": "Pushed {branch} to origin.", + "de": "Pushed {branch} to origin.", + "en": "Pushed {branch} to origin.", + "pl": "Pushed {branch} to origin.", + "ru": "Pushed {branch} to origin.", + "zh": "Pushed {branch} to origin." + }, + "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": { + "bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + "de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + "en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + "pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + "ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue", + "zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue" + }, + "Rebase failed with HTTP {status}: {message}": { + "bg": "Rebase failed with HTTP {status}: {message}", + "de": "Rebase failed with HTTP {status}: {message}", + "en": "Rebase failed with HTTP {status}: {message}", + "pl": "Rebase failed with HTTP {status}: {message}", + "ru": "Rebase failed with HTTP {status}: {message}", + "zh": "Rebase failed with HTTP {status}: {message}" + }, + "Rebase successful.": { + "bg": "Rebase successful.", + "de": "Rebase successful.", + "en": "Rebase successful.", + "pl": "Rebase successful.", + "ru": "Rebase successful.", + "zh": "Rebase successful." + }, + "Rebasing PR #{pr} via Gitea API...": { + "bg": "Rebasing PR #{pr} via Gitea API...", + "de": "Rebasing PR #{pr} via Gitea API...", + "en": "Rebasing PR #{pr} via Gitea API...", + "pl": "Rebasing PR #{pr} via Gitea API...", + "ru": "Rebasing PR #{pr} via Gitea API...", + "zh": "Rebasing PR #{pr} via Gitea API..." } } diff --git a/tests/unit/test_api_clients.py b/tests/unit/test_api_clients.py index 09d7817..aeb8422 100644 --- a/tests/unit/test_api_clients.py +++ b/tests/unit/test_api_clients.py @@ -233,6 +233,18 @@ class TestGiteaClient: json={"Do": "squash", "MergeTitleField": "fix: bug"}, ) + def test_update_pr_branch(self) -> None: + client = GiteaClient("https://git.example.com", "tok", "owner", "repo") + client._session.request = MagicMock(return_value=_mock_response()) + + client.update_pr_branch(7, style="rebase") + client._session.request.assert_called_once_with( + "POST", + "https://git.example.com/repos/owner/repo/pulls/7/update", + timeout=DEFAULT_TIMEOUT, + params={"style": "rebase"}, + ) + def test_get_pr_labels(self) -> None: client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}])) diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 16a3f45..0e3352d 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -292,14 +292,13 @@ class TestMain: @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.GiteaClient") - def test_merge_behind_master_raises_no_rebase( + def test_merge_behind_master_auto_rebases( self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch ) -> None: # type: ignore[no-untyped-def] - """When branch is behind master, auto-merge should NOT rebase. + """When branch is behind master, auto-merge rebases via Gitea API. - Auto-rebasing creates a feedback loop: the force-push triggers a new - pull_request synchronize event, which starts a new CI run, which runs - auto-merge again, which rebases again, etc. + The rebase triggers a new CI run. The next auto-merge attempt will + find the branch up-to-date and merge successfully. """ monkeypatch.chdir(tmp_path) @@ -315,12 +314,40 @@ class TestMain: main, ["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"], ) - assert result.exit_code != 0 + assert result.exit_code == 0 assert "behind master" in result.output.lower() - assert "rebase manually" in result.output.lower() - # Must NOT have called merge_pr twice (no retry after rebase) + assert "auto-rebasing" in result.output.lower() + # Should have called update_pr_branch to trigger server-side rebase + mock_client.update_pr_branch.assert_called_once_with(7, style="rebase") + # Must NOT have called merge_pr twice (no immediate retry) assert mock_client.merge_pr.call_count == 1 + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) + @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("devx.ci.auto_merge.GiteaClient") + def test_merge_behind_master_rebase_failure_raises( + self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch + ) -> None: # type: ignore[no-untyped-def] + """When auto-rebase fails, raise with manual rebase instructions.""" + monkeypatch.chdir(tmp_path) + + mock_client = MagicMock() + mock_client.get_pr_commits.return_value = [ + {"commit": {"message": "fix: resolve timeout"}}, + ] + mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master") + mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase") + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke( + main, + ["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"], + ) + assert result.exit_code != 0 + assert "auto-rebase failed" in result.output.lower() + assert "rebase manually" in result.output.lower() + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.GiteaClient") @@ -386,10 +413,10 @@ class TestMain: @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.GiteaClient") - def test_merge_behind_master_does_not_force_push( + def test_merge_behind_master_does_not_run_git_commands( self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch ) -> None: # type: ignore[no-untyped-def] - """Verify no git commands are run when branch is behind master.""" + """When behind master, auto-merge uses API rebase — no local git commands.""" monkeypatch.chdir(tmp_path) mock_client = MagicMock() @@ -405,8 +432,8 @@ class TestMain: main, ["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"], ) - assert result.exit_code != 0 - # No git commands should be run (no rebase, no push) + assert result.exit_code == 0 + # No local git commands should be run (rebase is via API) mock_run.assert_not_called() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2d8c5b9..ab5de0a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -201,6 +201,20 @@ class TestToolsCommands: assert result.exit_code == 0 mock_run.assert_called_once_with("devx.tools.setup", []) + @patch("devx.cli._run_module") + def test_tools_rebase(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"]) + + @patch("devx.cli._run_module") + def test_tools_pr_rebase(self, mock_run: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"]) + assert result.exit_code == 0 + mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"]) + class TestMoleculeCommands: @patch("devx.cli._run_module") diff --git a/tests/unit/test_rebase.py b/tests/unit/test_rebase.py new file mode 100644 index 0000000..af004b7 --- /dev/null +++ b/tests/unit/test_rebase.py @@ -0,0 +1,348 @@ +"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from devx.tools.pr_rebase import main as pr_rebase_main +from devx.tools.rebase import main as rebase_main + +_FULL_ENV = { + "CI_GITEA_TOKEN": "tok", + "DEVX_REPO_OWNER": "owner", + "DEVX_REPO_NAME": "repo", +} + + +class TestRunGitHelper: + """Tests for the _run_git helper function.""" + + @patch("devx.tools.rebase.subprocess.run") + def test_run_git_with_check(self, mock_run: MagicMock) -> None: + """_run_git passes check=True by default.""" + from devx.tools.rebase import _run_git + + mock_run.return_value = MagicMock(stdout="ok\n", returncode=0) + result = _run_git(["status"]) + mock_run.assert_called_once_with( + ["git", "status"], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout == "ok\n" + + @patch("devx.tools.rebase.subprocess.run") + def test_run_git_without_check(self, mock_run: MagicMock) -> None: + """_run_git passes check=False when specified.""" + from devx.tools.rebase import _run_git + + mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1) + result = _run_git(["rebase", "origin/master"], check=False) + mock_run.assert_called_once_with( + ["git", "rebase", "origin/master"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 1 + + +class TestDetectPrNumber: + """Tests for the detect_pr_number helper in _shared.""" + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.api_clients.GiteaClient") + def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: + """detect_pr_number returns PR number when branch has an open PR.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0) + mock_client = MagicMock() + mock_client.list_prs.return_value = [ + {"number": 42, "head": {"ref": "feature-branch"}}, + {"number": 99, "head": {"ref": "other-branch"}}, + ] + mock_client_cls.return_value = mock_client + + result = detect_pr_number() + assert result == 42 + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.api_clients.GiteaClient") + def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: + """detect_pr_number returns None when no open PR matches branch.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0) + mock_client = MagicMock() + mock_client.list_prs.return_value = [ + {"number": 42, "head": {"ref": "other-branch"}}, + ] + mock_client_cls.return_value = mock_client + + result = detect_pr_number() + assert result is None + + @patch("devx.tools._shared.subprocess.run") + def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None: + """detect_pr_number returns None on detached HEAD.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0) + result = detect_pr_number() + assert result is None + + @patch("devx.tools._shared.subprocess.run") + def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None: + """detect_pr_number returns None when git command fails.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1) + result = detect_pr_number() + assert result is None + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", {}, clear=True) + def test_detect_pr_no_token(self, mock_run: MagicMock) -> None: + """detect_pr_number returns None when CI_GITEA_TOKEN is not set.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) + result = detect_pr_number() + assert result is None + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True) + @patch("devx.api_clients.GiteaClient") + def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: + """detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) + mock_client = MagicMock() + mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}] + mock_client_cls.return_value = mock_client + + result = detect_pr_number() + assert result == 7 + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True) + def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None: + """GITHUB_REPOSITORY without slash is ignored, returns None.""" + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) + result = detect_pr_number() + assert result is None + + @patch("devx.tools._shared.subprocess.run") + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.api_clients.GiteaClient") + def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None: + """detect_pr_number returns None when API call fails (best-effort).""" + from devx.api_clients import APIError + from devx.tools._shared import detect_pr_number + + mock_run.return_value = MagicMock(stdout="feature\n", returncode=0) + mock_client = MagicMock() + mock_client.list_prs.side_effect = APIError(401, "Unauthorized") + mock_client_cls.return_value = mock_client + + result = detect_pr_number() + assert result is None + + +class TestRebaseTool: + """Tests for the local rebase tool (devx.tools.rebase).""" + + @patch("devx.tools.rebase._run_git") + def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None: + """When branch is up-to-date, no rebase or push happens.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", returncode=0), # fetch + MagicMock(stdout="0\n", returncode=0), # rev-list --count + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code == 0 + assert "already up-to-date" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None: + """When behind master, rebase and force-push.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", returncode=0), # fetch + MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2) + MagicMock(stdout="", stderr="", returncode=0), # rebase + MagicMock(stdout="", stderr="", returncode=0), # push + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code == 0 + assert "2 commit(s) behind" in result.output + assert "rebase successful" in result.output.lower() + assert "pushed" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None: + """With --no-push, rebase happens but no push.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", returncode=0), # fetch + MagicMock(stdout="1\n", returncode=0), # rev-list --count + MagicMock(stdout="", stderr="", returncode=0), # rebase + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, ["--no-push"]) + assert result.exit_code == 0 + assert "rebase successful" in result.output.lower() + # Only 4 git calls (no push) + assert mock_run_git.call_count == 4 + + @patch("devx.tools.rebase._run_git") + def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None: + """Detached HEAD should fail immediately.""" + mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0) + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code != 0 + assert "detached" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None: + """Git rev-parse failure should exit with error.""" + mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1) + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code != 0 + assert "could not detect" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None: + """Rebase conflict should exit with error.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", returncode=0), # fetch + MagicMock(stdout="1\n", returncode=0), # rev-list --count + MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code != 0 + assert "rebase failed" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None: + """Fetch failure should exit with error.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code != 0 + assert "fetch failed" in result.output.lower() + + @patch("devx.tools.rebase._run_git") + def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None: + """Force-push rejection should exit with error.""" + mock_run_git.side_effect = [ + MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse + MagicMock(stdout="", returncode=0), # fetch + MagicMock(stdout="1\n", returncode=0), # rev-list --count + MagicMock(stdout="", stderr="", returncode=0), # rebase + MagicMock(stdout="", stderr="rejected", returncode=1), # push fails + ] + + runner = CliRunner() + result = runner.invoke(rebase_main, []) + assert result.exit_code != 0 + assert "force-push failed" in result.output.lower() + + +class TestPrRebaseTool: + """Tests for the server-side PR rebase tool (devx.tools.pr_rebase).""" + + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.tools.pr_rebase.GiteaClient") + def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None: + """Successful API rebase prints confirmation.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(pr_rebase_main, ["--pr", "42"]) + assert result.exit_code == 0 + assert "rebased successfully" in result.output.lower() + mock_client.update_pr_branch.assert_called_once_with(42, style="rebase") + + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.tools.pr_rebase.GiteaClient") + def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None: + """API error during rebase exits with error.""" + from devx.api_clients import APIError + + mock_client = MagicMock() + mock_client.update_pr_branch.side_effect = APIError(409, "Conflict") + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(pr_rebase_main, ["--pr", "42"]) + assert result.exit_code != 0 + assert "rebase failed" in result.output.lower() + + @patch("devx.tools.pr_rebase.load_dotenv") + @patch.dict("os.environ", {}, clear=True) + def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None: + """Missing CI_GITEA_TOKEN should fail.""" + runner = CliRunner() + result = runner.invoke(pr_rebase_main, ["--pr", "42"]) + assert result.exit_code != 0 + assert "CI_GITEA_TOKEN" in result.output + + @patch.dict("os.environ", _FULL_ENV, clear=True) + @patch("devx.tools.pr_rebase.detect_pr_number", return_value=None) + def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None: + """When PR number can't be auto-detected, fail with instructions.""" + runner = CliRunner() + result = runner.invoke(pr_rebase_main, []) + assert result.exit_code != 0 + assert "could not detect" in result.output.lower() + + @patch("devx.tools.pr_rebase.load_dotenv") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True) + @patch("devx.tools.pr_rebase.GiteaClient") + def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None: + """Missing repo env vars should fail.""" + runner = CliRunner() + result = runner.invoke(pr_rebase_main, ["--pr", "42"]) + assert result.exit_code != 0 + assert "DEVX_REPO_OWNER" in result.output + + @patch("devx.tools.pr_rebase.load_dotenv") + @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True) + @patch("devx.tools.pr_rebase.GiteaClient") + def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None: + """GITHUB_REPOSITORY env var is used as fallback for owner/repo.""" + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + runner = CliRunner() + result = runner.invoke(pr_rebase_main, ["--pr", "42"]) + assert result.exit_code == 0 + mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")