DEVX-104: feat: auto-rebase in auto-merge, new rebase tools, CLI registration
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / release (push) Successful in 44s
Post-merge / badges (push) Successful in 50s
Post-merge / publish (push) Successful in 20s
Build Images / detect-type (push) Successful in 1m26s
Build Images / build-and-push (push) Successful in 5m35s
Build Images / cleanup (push) Successful in 3m32s

This commit was merged in pull request #161.
This commit is contained in:
2026-07-01 00:50:23 +00:00
parent 66554657f2
commit 621b051793
16 changed files with 922 additions and 32 deletions
+11 -1
View File
@@ -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 |
+2
View File
@@ -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."
+1 -1
View File
@@ -131,7 +131,7 @@ wiki sync details.
devx provides a `devx` CLI with three command groups:
- `devx ci <command>` — CI/CD automation (17 commands)
- `devx tools <command>` — Developer tools (7 commands)
- `devx tools <command>` — Developer tools (9 commands)
- `devx molecule <command>` — Molecule testing (4 commands, optional)
See [CLI Commands](CLI-Commands) for full command documentation with examples.
+29
View File
@@ -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 <N>` — PR number (auto-detected from current branch if omitted)
## Molecule Commands
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
+13
View File
@@ -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.
+27 -9
View File
@@ -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(
_(
+14
View File
@@ -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])."""
+15 -1
View File
@@ -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
+51
View File
@@ -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
+97
View File
@@ -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()
+97
View File
@@ -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()
+152 -8
View File
@@ -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..."
}
}
+12
View File
@@ -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"}]))
+39 -12
View File
@@ -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()
+14
View File
@@ -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")
+348
View File
@@ -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")