Public Access
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
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:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}]))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user