GRM-54: Integrate tea Gitea CLI for API interactions (#68)
This commit is contained in:
@@ -63,7 +63,7 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
@@ -156,7 +156,7 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
|
||||
@@ -14,14 +14,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install CI tools
|
||||
run: python3 scripts/install_tools.py --tool git-cliff
|
||||
run: python3 scripts/install_tools.py --tool git-cliff --tool tea
|
||||
- name: Install build tools
|
||||
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Configure tea login
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default grm || true
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/publish.py \
|
||||
@@ -31,8 +38,9 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
|
||||
@@ -19,7 +19,8 @@ make workflow-check # workflow-lint + workflow-dryrun
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
||||
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter)
|
||||
- **actionlint, git-cliff, act_runner** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
|
||||
- **actionlint, git-cliff, act_runner, tea** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
|
||||
- **tea CLI login** via `scripts/setup.py` (configures `tea login` from `.env` `REPO_TOKEN`)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
|
||||
@@ -243,7 +244,7 @@ types from accidentally skipping releases.
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
**Script directory structure:**
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`, `gitea_cli.py`
|
||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `discover_runners.py`, `notify_failure.py`, `post_merge.py`, `pr_review.py`, `review_pr.py`, `validate_commit_msg.py`, `platforms.py`
|
||||
|
||||
**CI behavior based on classification:**
|
||||
@@ -276,6 +277,31 @@ The codebase enforces strict separation between the GRM tool and CI/dev scripts:
|
||||
1. **`src/gitea_runner_manager/` NEVER imports from `scripts/`** — the tool is self-contained
|
||||
2. **Scripts MAY import from `gitea_runner_manager`** — one-way dependency (scripts use the tool's API clients, config, i18n)
|
||||
3. **Cross-script imports** (scripts importing from other scripts) are allowed within `scripts/ci/` but must be documented
|
||||
4. **`scripts/gitea_cli.py`** is a shared wrapper around the `tea` CLI — CI scripts import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
|
||||
|
||||
### tea CLI Integration
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions in CI scripts. It is installed by `scripts/install_tools.py` and configured by `scripts/setup.py` (login profile from `.env` `REPO_TOKEN`).
|
||||
|
||||
**`scripts/gitea_cli.py`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||
- `TeaCLI.create_issue()` — Create issues with labels
|
||||
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
|
||||
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
|
||||
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
|
||||
- `TeaCLI.list_branches()` — Branch listing
|
||||
|
||||
**Scripts using tea (via `gitea_cli.py`):**
|
||||
- `scripts/ci/review_pr.py` — Posts PR reviews via `tea pulls review`
|
||||
- `scripts/ci/publish.py` — Creates Gitea releases via `tea releases create`
|
||||
- `scripts/ci/notify_failure.py` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
|
||||
- `scripts/configure_repo.py` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
|
||||
|
||||
**Operations still using `GiteaClient` (not supported by tea):**
|
||||
- Wiki page management (`sync_wiki.py`)
|
||||
- Commit status checks (`auto_merge.py`)
|
||||
- Runner discovery (`discover_runners.py`)
|
||||
- Branch protection with detailed config (`configure_repo.py`)
|
||||
- PR file/commit listing (`pr_review.py`)
|
||||
|
||||
### PYTHONPATH Configuration
|
||||
|
||||
@@ -283,7 +309,8 @@ Scripts have different import requirements. Workflows must set `PYTHONPATH` acco
|
||||
|
||||
| PYTHONPATH | When to use | Example scripts |
|
||||
|------------|-------------|-----------------|
|
||||
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `notify_failure.py`, `sync_wiki.py`, `post_merge.py`, `publish.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` |
|
||||
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `sync_wiki.py`, `post_merge.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` |
|
||||
| `.:src` | Script imports from both `gitea_runner_manager` and `scripts.gitea_cli` | `review_pr.py`, `publish.py`, `notify_failure.py`, `configure_repo.py` |
|
||||
| `.` | Script imports from other `scripts.ci.*` modules | `release.py` (imports `classify_changes.has_user_facing_changes`) |
|
||||
| (none) | Script has no GRM or cross-script imports | `detect_release_commit.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `push_badges.py`, `validate_commit_msg.py` |
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"""Create a Gitea issue when a CI workflow fails.
|
||||
|
||||
Used by the release and publish workflows to alert on failures that would
|
||||
otherwise go unnoticed in the Actions tab.
|
||||
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
||||
for issue creation when available, falling back to ``GiteaClient`` (direct
|
||||
HTTP API) when tea is not installed.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
|
||||
@@ -14,7 +16,9 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -27,6 +31,48 @@ from gitea_runner_manager.i18n import _
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int | None:
|
||||
"""Try creating issue via tea CLI. Returns issue index or None on failure."""
|
||||
if shutil.which("tea") is None:
|
||||
return None
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
try:
|
||||
# Check if "bug" label exists
|
||||
labels: list[str] = []
|
||||
try:
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
except TeaCLIError:
|
||||
pass
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
return int(issue.get("index", 0))
|
||||
except TeaCLIError:
|
||||
return None
|
||||
|
||||
|
||||
def _create_issue_via_client(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via GiteaClient (direct HTTP API). Returns issue ID."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
return int(issue.get("id", 0))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@@ -37,9 +83,6 @@ def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
@@ -50,23 +93,20 @@ def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
f"Please investigate and fix the issue."
|
||||
)
|
||||
|
||||
try:
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
# Try tea CLI first, fall back to GiteaClient
|
||||
issue_id = _create_issue_via_tea(repo, title, body)
|
||||
if issue_id is None:
|
||||
try:
|
||||
issue_id = _create_issue_via_client(repo, title, body)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Created issue #{issue_id}: {title}",
|
||||
issue_id=issue.get("id", "?"),
|
||||
issue_id=issue_id or "?",
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
+6
-18
@@ -2,6 +2,7 @@
|
||||
"""Build package, optionally publish to PyPI, and create Gitea release.
|
||||
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
|
||||
@@ -15,10 +16,8 @@ import sys
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -109,24 +108,13 @@ def main(tag: str, repo: str) -> None:
|
||||
else:
|
||||
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name)
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
client.create_release_idempotent(
|
||||
tag=tag,
|
||||
body=release_body,
|
||||
)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Release creation failed with HTTP {status}: {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
+9
-20
@@ -3,9 +3,9 @@
|
||||
|
||||
Used by the GRM workflow to post structured PR reviews. The review body
|
||||
is provided via --body and inline comments via a JSON file
|
||||
(--comments-json) or stdin (--comments-stdin). This script is a thin
|
||||
CLI wrapper around ``GiteaClient.create_review`` — the actual review
|
||||
analysis is performed by the agent before invoking this tool.
|
||||
(--comments-json) or stdin (--comments-stdin). This script uses the
|
||||
``tea`` Gitea CLI for posting the review — the actual review analysis is
|
||||
performed by the agent before invoking this tool.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
|
||||
@@ -36,10 +36,8 @@ from typing import Any
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -107,8 +105,7 @@ def main(
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
comments = parse_comments(comments_json, comments_stdin)
|
||||
|
||||
@@ -132,21 +129,13 @@ def main(
|
||||
)
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Failed to post review: HTTP {status} — {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
tea.review_pr(repo, int(pr_number), event=event, body=body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Failed to post review: {error}", error=str(e))) from None
|
||||
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"Review #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
review_id=review_id,
|
||||
"Review posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
num_comments=len(comments),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure GRM repository: branch protection + labels via Gitea REST API.
|
||||
|
||||
Uses the ``tea`` Gitea CLI for label creation and the ``GiteaClient`` for
|
||||
branch protection and repo settings (tea only supports basic protect/unprotect,
|
||||
not the detailed config we need with status checks and required approvals).
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/configure_repo.py
|
||||
"""
|
||||
@@ -23,6 +27,7 @@ from gitea_runner_manager.config import (
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -41,12 +46,42 @@ def _handle_http_error(e: APIError) -> None:
|
||||
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
|
||||
|
||||
|
||||
def _ensure_label_via_tea(tea: TeaCLI, repo: str, name: str, color: str, description: str) -> bool:
|
||||
"""Create a label via tea if it doesn't already exist.
|
||||
|
||||
Returns True if created, False if it already existed.
|
||||
"""
|
||||
try:
|
||||
existing = tea.list_labels(repo)
|
||||
if any(label.get("name") == name for label in existing):
|
||||
return False
|
||||
tea.create_label(repo, name=name, color=color, description=description)
|
||||
return True
|
||||
except TeaCLIError:
|
||||
# Fall back to GiteaClient if tea fails
|
||||
return _ensure_label_via_client(name, color, description)
|
||||
|
||||
|
||||
def _ensure_label_via_client(name: str, color: str, description: str) -> bool:
|
||||
"""Fallback: create label via GiteaClient. Returns True if created."""
|
||||
client = GiteaClient(
|
||||
GITEA_API_URL,
|
||||
os.environ.get("REPO_TOKEN", ""),
|
||||
REPO_OWNER,
|
||||
REPO_NAME,
|
||||
)
|
||||
result = client.ensure_label(name=name, color=color, description=description)
|
||||
return result is not None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
repo = f"{REPO_OWNER}/{REPO_NAME}"
|
||||
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
try:
|
||||
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
|
||||
@@ -67,15 +102,17 @@ def main() -> None:
|
||||
click.echo("")
|
||||
label_name = cast(str, LABEL_CONFIG["name"])
|
||||
click.echo(_("Creating {label} label...", label=label_name))
|
||||
result = client.ensure_label(
|
||||
name=cast(str, LABEL_CONFIG["name"]),
|
||||
created = _ensure_label_via_tea(
|
||||
tea,
|
||||
repo,
|
||||
name=label_name,
|
||||
color=cast(str, LABEL_CONFIG["color"]),
|
||||
description=cast(str, LABEL_CONFIG["description"]),
|
||||
)
|
||||
if result is None:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
else:
|
||||
if created:
|
||||
click.echo(_(" Label '{label}' created.", label=label_name))
|
||||
else:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Configuring repository settings..."))
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
|
||||
|
||||
This module provides a programmatic interface to the ``tea`` CLI tool,
|
||||
parsing JSON output for structured data. It is used by CI scripts to
|
||||
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
|
||||
for reliability.
|
||||
|
||||
The wrapper requires ``tea`` to be installed and configured (run
|
||||
``make setup`` which calls ``scripts/install_tools.py`` and
|
||||
``scripts/setup.py``).
|
||||
|
||||
Operations supported via tea:
|
||||
- Creating pull requests
|
||||
- Creating issues
|
||||
- Adding labels to issues/PRs
|
||||
- Creating labels
|
||||
- Merging pull requests
|
||||
- Creating releases
|
||||
- Posting reviews on PRs
|
||||
- Listing branches
|
||||
|
||||
Operations NOT supported via tea (still use GiteaClient):
|
||||
- Wiki page management
|
||||
- Commit status checks
|
||||
- Runner discovery
|
||||
- PR file/commit listing (tea has limited support)
|
||||
- Branch protection with detailed config (tea only has basic protect/unprotect)
|
||||
|
||||
Usage::
|
||||
|
||||
from scripts.gitea_cli import TeaCLI
|
||||
|
||||
tea = TeaCLI()
|
||||
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
|
||||
tea.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
All methods parse JSON output from tea for structured access.
|
||||
Commands are run with ``--output json`` where structured data is expected.
|
||||
"""
|
||||
|
||||
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
|
||||
"""Initialize the tea CLI wrapper.
|
||||
|
||||
Args:
|
||||
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
|
||||
repo: Default repo in ``owner/name`` format for commands that need it.
|
||||
"""
|
||||
self._tea = tea_bin or shutil.which("tea") or "tea"
|
||||
self._repo = repo
|
||||
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
|
||||
Returns:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
return self._run(args, json_output=False)
|
||||
|
||||
def _repo_arg(self, repo: str | None = None) -> list[str]:
|
||||
"""Build the --repo argument list."""
|
||||
target = repo or self._repo
|
||||
if target:
|
||||
return ["--repo", target]
|
||||
return []
|
||||
|
||||
# -- Issues --
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create an issue and return the issue dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: Issue title.
|
||||
body: Issue body (markdown).
|
||||
labels: List of label names to attach.
|
||||
|
||||
Returns:
|
||||
The created issue as a dict (parsed from tea JSON output).
|
||||
"""
|
||||
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
|
||||
output = self._run(args, json_output=False)
|
||||
# tea issues create doesn't output JSON; extract issue number from output
|
||||
# Format: "Created issue #42: <title>"
|
||||
issue_index = _extract_issue_number(output)
|
||||
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
|
||||
|
||||
# -- Labels --
|
||||
|
||||
def list_labels(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all labels for a repository."""
|
||||
output = self._run(["labels", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
def create_label(
|
||||
self,
|
||||
repo: str,
|
||||
name: str,
|
||||
color: str = "",
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a label. Returns the label dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
name: Label name.
|
||||
color: Hex color (without #), e.g. ``2ecc71``.
|
||||
description: Label description.
|
||||
"""
|
||||
args = ["labels", "create", name, *self._repo_arg(repo)]
|
||||
if color:
|
||||
args.extend(["--color", f"#{color}"])
|
||||
if description:
|
||||
args.extend(["--description", description])
|
||||
output = self._run(args, json_output=False)
|
||||
return {"name": name, "color": color, "description": description, "output": output}
|
||||
|
||||
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
|
||||
"""Add labels to an issue or PR.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
issue_index: Issue or PR number.
|
||||
labels: List of label names to add.
|
||||
"""
|
||||
for _label in labels:
|
||||
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
|
||||
return # tea edit handles all labels at once
|
||||
# No labels to add — nothing to do
|
||||
|
||||
# -- Pull Requests --
|
||||
|
||||
def create_pr(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
head: str,
|
||||
base: str,
|
||||
body: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pull request and return the PR dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: PR title.
|
||||
head: Head branch name.
|
||||
base: Base branch name.
|
||||
body: PR description (markdown).
|
||||
"""
|
||||
args = [
|
||||
"pulls",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--base",
|
||||
base,
|
||||
"--head",
|
||||
head,
|
||||
*self._repo_arg(repo),
|
||||
]
|
||||
if body:
|
||||
args.extend(["--body", body])
|
||||
output = self._run(args, json_output=False)
|
||||
pr_index = _extract_pr_number(output)
|
||||
return {"title": title, "index": pr_index, "url": output.strip()}
|
||||
|
||||
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
|
||||
"""
|
||||
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
|
||||
|
||||
def review_pr(
|
||||
self,
|
||||
repo: str,
|
||||
pr_index: int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
) -> None:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
|
||||
body: Review body text.
|
||||
"""
|
||||
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
|
||||
if event == "APPROVE":
|
||||
args.append("--approve")
|
||||
elif event == "REQUEST_CHANGES":
|
||||
args.extend(["--reject"])
|
||||
if body:
|
||||
args.extend(["--comment", body])
|
||||
self._run_raw(args)
|
||||
|
||||
# -- Releases --
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
repo: str,
|
||||
tag: str,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
target: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release and return the release dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
tag: Tag name (e.g. ``v1.0.0``).
|
||||
title: Release title.
|
||||
body: Release notes (markdown).
|
||||
target: Target branch/commit for the tag.
|
||||
draft: If True, create as draft.
|
||||
prerelease: If True, mark as prerelease.
|
||||
"""
|
||||
args = ["releases", "create", tag, *self._repo_arg(repo)]
|
||||
if title:
|
||||
args.extend(["--title", title])
|
||||
if body:
|
||||
args.extend(["--note", body])
|
||||
if target:
|
||||
args.extend(["--target", target])
|
||||
if draft:
|
||||
args.append("--draft")
|
||||
if prerelease:
|
||||
args.append("--prerelease")
|
||||
output = self._run(args, json_output=False)
|
||||
return {"tag": tag, "title": title, "url": output.strip()}
|
||||
|
||||
def list_releases(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all releases for a repository."""
|
||||
output = self._run(["releases", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Branches --
|
||||
|
||||
def list_branches(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all branches for a repository."""
|
||||
output = self._run(["branches", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Utility --
|
||||
|
||||
def whoami(self) -> str:
|
||||
"""Return the current authenticated user."""
|
||||
return self._run_raw(["whoami"])
|
||||
|
||||
|
||||
def _extract_issue_number(output: str) -> int:
|
||||
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
|
||||
for part in output.split():
|
||||
if part.startswith("#"):
|
||||
try:
|
||||
return int(part[1:].rstrip(":"))
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_pr_number(output: str) -> int:
|
||||
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
|
||||
return _extract_issue_number(output)
|
||||
@@ -5,6 +5,7 @@ Handles installation of:
|
||||
- actionlint (workflow YAML linter)
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -36,6 +37,8 @@ GIT_CLIFF_VERSION = "2.13.0"
|
||||
|
||||
ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
TEA_VERSION = "0.14.1"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
@@ -144,7 +147,19 @@ def install_act_runner() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner"]
|
||||
def install_tea() -> bool:
|
||||
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("tea"):
|
||||
click.echo("tea: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
||||
dest = _download_binary(url, "tea")
|
||||
click.echo(f"tea: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -155,6 +170,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_git_cliff()
|
||||
if name == "act_runner":
|
||||
return install_act_runner()
|
||||
if name == "tea":
|
||||
return install_tea()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
@@ -166,15 +183,21 @@ def list_tools() -> None:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--tool", type=click.Choice(TOOL_NAMES), help="Install a specific tool.")
|
||||
@click.option(
|
||||
"--tool",
|
||||
"tools",
|
||||
multiple=True,
|
||||
type=click.Choice(TOOL_NAMES),
|
||||
help="Install specific tool(s). Can be repeated.",
|
||||
)
|
||||
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
|
||||
def main(tool: str | None, list_status: bool) -> None:
|
||||
def main(tools: tuple[str, ...], list_status: bool) -> None:
|
||||
"""Install CI/CD development tools to ~/.local/bin."""
|
||||
if list_status:
|
||||
list_tools()
|
||||
return
|
||||
|
||||
tools_to_install = [tool] if tool else TOOL_NAMES
|
||||
tools_to_install = list(tools) if tools else TOOL_NAMES
|
||||
failed: list[str] = []
|
||||
for name in tools_to_install:
|
||||
try:
|
||||
|
||||
+63
-1
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
|
||||
|
||||
Replaces the previous ``scripts/setup.sh`` with a tested Python equivalent.
|
||||
Also configures the ``tea`` Gitea CLI login profile from ``.env`` so that
|
||||
CI scripts and dev tools can use ``tea`` for Gitea API operations.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -10,10 +11,15 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _run(cmd: list[str], bin_dir: str) -> None:
|
||||
@@ -45,6 +51,59 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
"""Configure tea CLI login from .env if REPO_TOKEN is set.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or REPO_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — skipping login configuration.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
# Derive the Gitea URL from the API URL (strip /api/v1 suffix)
|
||||
api_url = os.environ.get("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
gitea_url = api_url.replace("/api/v1", "")
|
||||
login_name = "grm"
|
||||
|
||||
# Check if login already exists
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(f"tea: login '{login_name}' already configured.")
|
||||
return
|
||||
|
||||
# Add login profile
|
||||
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
|
||||
add_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
|
||||
else:
|
||||
# Set as default login
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
click.echo(f"tea: login '{login_name}' configured and set as default.")
|
||||
|
||||
|
||||
def _verify(bin_dir: str) -> None:
|
||||
"""Print versions of installed tools for verification."""
|
||||
grm = str(Path(bin_dir) / "grm")
|
||||
@@ -74,6 +133,9 @@ def main(bin_dir: str) -> None:
|
||||
click.echo("Installing pre-commit hooks...")
|
||||
_install_pre_commit_hooks(bin_dir)
|
||||
|
||||
click.echo("Configuring tea CLI login...")
|
||||
_configure_tea_login()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Setup complete.")
|
||||
click.echo("Activate the virtual environment with one of:")
|
||||
|
||||
@@ -9,9 +9,12 @@ import pytest
|
||||
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.configure_repo import (
|
||||
_ensure_label_via_client,
|
||||
_ensure_label_via_tea,
|
||||
_handle_http_error,
|
||||
main,
|
||||
)
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestHandleHttpError:
|
||||
@@ -36,6 +39,50 @@ class TestHandleHttpError:
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
|
||||
|
||||
|
||||
class TestEnsureLabelViaTea:
|
||||
def test_creates_new_label(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "bug"}]
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is True
|
||||
mock_tea.create_label.assert_called_once()
|
||||
|
||||
def test_label_already_exists(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is False
|
||||
mock_tea.create_label.assert_not_called()
|
||||
|
||||
def test_tea_error_falls_back_to_client(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
with patch("scripts.configure_repo._ensure_label_via_client", return_value=True) as mock_fallback:
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is True
|
||||
mock_fallback.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||
|
||||
|
||||
class TestEnsureLabelViaClient:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_creates_label(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = {"id": 1}
|
||||
mock_client_cls.return_value = mock_client
|
||||
result = _ensure_label_via_client("bug", "ff0000", "A bug")
|
||||
assert result is True
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_label_already_exists(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
result = _ensure_label_via_client("bug", "ff0000", "A bug")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_missing_token(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
@@ -43,57 +90,90 @@ class TestMain:
|
||||
main()
|
||||
assert "REPO_TOKEN" in str(exc.value)
|
||||
|
||||
def test_main_success(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_success(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [] # No existing labels
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
main()
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_tea.create_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_label_already_exists(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_label_already_exists(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
main()
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_tea.create_label.assert_not_called()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_api_error(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_tea_error_falls_back(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = {"id": 1}
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
# Fallback to GiteaClient for label creation
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_api_error(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
import scripts.configure_repo as cr
|
||||
with patch("scripts.configure_repo.TeaCLI") as mock_tea_cls:
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
import scripts.configure_repo as cr
|
||||
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
# Remove __main__ block so exec doesn't call main() before we inject the mock
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
# Remove __main__ block so exec doesn't call main() before we inject the mock
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["TeaCLI"] = mock_tea_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
def test_extract_from_created_issue(self) -> None:
|
||||
assert _extract_issue_number("Created issue #42: Bug title") == 42
|
||||
|
||||
def test_extract_no_hash(self) -> None:
|
||||
assert _extract_issue_number("No issue number here") == 0
|
||||
|
||||
def test_extract_multiple_hashes(self) -> None:
|
||||
assert _extract_issue_number("Issue #5 and PR #10") == 5
|
||||
|
||||
def test_extract_with_colon(self) -> None:
|
||||
assert _extract_issue_number("Created issue #7: title") == 7
|
||||
|
||||
def test_extract_invalid_number(self) -> None:
|
||||
assert _extract_issue_number("Issue #abc: title") == 0
|
||||
|
||||
|
||||
class TestExtractPrNumber:
|
||||
def test_extract_from_created_pr(self) -> None:
|
||||
assert _extract_pr_number("Created PR #128: Feature") == 128
|
||||
|
||||
def test_extract_no_number(self) -> None:
|
||||
assert _extract_pr_number("No PR number") == 0
|
||||
|
||||
|
||||
class TestTeaCLIInit:
|
||||
def test_auto_detect_tea(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "/usr/bin/tea"
|
||||
|
||||
def test_explicit_tea_bin(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/custom/tea")
|
||||
assert cli._tea == "/custom/tea"
|
||||
|
||||
def test_fallback_to_tea(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "tea"
|
||||
|
||||
def test_with_repo(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo == "owner/repo"
|
||||
|
||||
|
||||
class TestTeaCLIRun:
|
||||
def test_run_success_json(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run(["labels", "list"])
|
||||
assert output == '[{"id": 1}]'
|
||||
|
||||
def test_run_success_raw(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run_raw(["issues", "create"])
|
||||
assert output == "Created issue #42"
|
||||
|
||||
def test_run_failure_raises(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_includes_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run(["labels", "list"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_run_raw_no_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run_raw(["whoami"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" not in cmd
|
||||
|
||||
|
||||
class TestRepoArg:
|
||||
def test_with_repo_arg(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo_arg() == ["--repo", "owner/repo"]
|
||||
|
||||
def test_with_explicit_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
|
||||
|
||||
def test_without_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg() == []
|
||||
|
||||
def test_explicit_overrides_default(self) -> None:
|
||||
cli = TeaCLI(repo="default/repo")
|
||||
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
|
||||
|
||||
|
||||
class TestCreateIssue:
|
||||
def test_create_issue_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
|
||||
assert issue["index"] == 42
|
||||
assert issue["title"] == "Bug"
|
||||
|
||||
def test_create_issue_with_labels(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
|
||||
assert issue["index"] == 5
|
||||
|
||||
|
||||
class TestListLabels:
|
||||
def test_list_labels_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert len(labels) == 2
|
||||
assert labels[0]["name"] == "bug"
|
||||
|
||||
def test_list_labels_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert labels == []
|
||||
|
||||
|
||||
class TestCreateLabel:
|
||||
def test_create_label_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
|
||||
assert label["name"] == "bug"
|
||||
assert label["color"] == "ff0000"
|
||||
|
||||
def test_create_label_name_only(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="wip")
|
||||
assert label["name"] == "wip"
|
||||
assert label["color"] == ""
|
||||
|
||||
|
||||
class TestAddLabel:
|
||||
def test_add_label_single(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
assert "ready-to-merge" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_add_label_multiple(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["bug", "urgent"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
|
||||
def test_add_label_empty_list(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
cli.add_label("owner/repo", 42, [])
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestCreatePR:
|
||||
def test_create_pr_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
|
||||
assert pr["index"] == 128
|
||||
|
||||
def test_create_pr_with_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--body" in cmd
|
||||
assert "Description" in cmd
|
||||
|
||||
|
||||
class TestMergePR:
|
||||
def test_merge_pr_squash(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42, style="squash")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--style" in cmd
|
||||
assert "squash" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_merge_pr_default_style(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "squash" in cmd
|
||||
|
||||
|
||||
class TestReviewPR:
|
||||
def test_review_approve(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_reject(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--reject" in cmd
|
||||
|
||||
def test_review_comment(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" not in cmd
|
||||
assert "--reject" not in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_no_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--comment" not in cmd
|
||||
|
||||
|
||||
class TestCreateRelease:
|
||||
def test_create_release_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release(
|
||||
"owner/repo",
|
||||
tag="v1.0.0",
|
||||
title="Release 1.0.0",
|
||||
body="Notes",
|
||||
target="master",
|
||||
)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "v1.0.0" in cmd
|
||||
assert "--title" in cmd
|
||||
assert "--note" in cmd
|
||||
assert "--target" in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
def test_create_release_draft(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--draft" in cmd
|
||||
|
||||
def test_create_release_prerelease(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--prerelease" in cmd
|
||||
|
||||
def test_create_release_minimal(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release("owner/repo", tag="v1.0.0")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--title" not in cmd
|
||||
assert "--note" not in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
|
||||
class TestListReleases:
|
||||
def test_list_releases_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert len(releases) == 2
|
||||
|
||||
def test_list_releases_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert releases == []
|
||||
|
||||
|
||||
class TestListBranches:
|
||||
def test_list_branches_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert len(branches) == 2
|
||||
|
||||
def test_list_branches_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert branches == []
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
def test_whoami(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert cli.whoami() == "emil"
|
||||
@@ -204,6 +204,24 @@ class TestInstallActRunner:
|
||||
assert (tmp_path / "act_runner").exists()
|
||||
|
||||
|
||||
class TestInstallTea:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_tea() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_tea() is True
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -228,6 +246,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("act_runner") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_tea(self) -> None:
|
||||
with patch.object(install_tools, "install_tea", return_value=True) as mock:
|
||||
assert install_tools._install_tool("tea") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -246,7 +269,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 3
|
||||
assert mock_install.call_count == 4
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -255,6 +278,13 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once_with("actionlint")
|
||||
|
||||
def test_install_multiple_specific_tools(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 2
|
||||
|
||||
def test_install_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.notify_failure import main
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_creates_issue_with_labels(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 42}
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -33,64 +36,127 @@ class TestNotifyFailure:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #42" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs["labels"] == [5]
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_creates_issue_without_bug_label(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When 'bug' label doesn't exist, create issue without labels."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_client.create_issue.return_value = {"id": 43}
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"124",
|
||||
"--workflow",
|
||||
"publish",
|
||||
"--commit",
|
||||
"def789",
|
||||
],
|
||||
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #43" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs.get("labels") is None
|
||||
mock_tea.add_label.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_error_falls_back_to_client(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""When tea fails, fall back to GiteaClient."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with patch("scripts.ci.notify_failure.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 50}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_api_error_raises(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_tea_not_installed_uses_client(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""When tea is not installed, use GiteaClient directly."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
mock_client.create_issue.side_effect = APIError(403, "forbidden")
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 51}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"125",
|
||||
"--workflow",
|
||||
"release",
|
||||
"--commit",
|
||||
"abc",
|
||||
],
|
||||
["--repo", "owner/repo", "--run-id", "126", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_client_api_error_raises(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
mock_client.create_issue.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "127", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "403" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_list_labels_error_continues_without_labels(
|
||||
self, mock_tea_cls: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""If listing labels fails via tea, issue is still created without labels."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""If adding label fails via tea, issue is still reported as created."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
|
||||
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
+23
-42
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for scripts/ci/publish.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -13,6 +12,7 @@ from scripts.ci.publish import (
|
||||
main,
|
||||
publish_to_pypi,
|
||||
)
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@@ -97,42 +97,45 @@ class TestPublishToPypi:
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_build.assert_called_once()
|
||||
mock_publish.assert_called_once_with("pypi-tok")
|
||||
mock_client_cls.return_value.create_release_idempotent.assert_called_once()
|
||||
# Verify release body uses git-cliff notes
|
||||
call_args = mock_client_cls.return_value.create_release_idempotent.call_args
|
||||
assert call_args.kwargs["body"] == "Release notes"
|
||||
mock_tea.create_release.assert_called_once_with(
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
mock_build.assert_called_once()
|
||||
mock_client_cls.return_value.create_release_idempotent.assert_called_once()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@@ -144,11 +147,11 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_build.side_effect = click.ClickException("build failed")
|
||||
runner = CliRunner()
|
||||
@@ -158,11 +161,11 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_publish.side_effect = click.ClickException("publish failed")
|
||||
runner = CliRunner()
|
||||
@@ -172,38 +175,16 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release_idempotent.side_effect = APIError(
|
||||
http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error"
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_release_json_parse_failure(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release_idempotent.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
assert "Release creation failed" in result.output
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for scripts/ci/review_pr.py."""
|
||||
|
||||
import http
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -8,8 +7,8 @@ import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.review_pr import main, parse_comments
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestParseComments:
|
||||
@@ -60,27 +59,25 @@ class TestParseComments:
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 42}
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_successful_comment_review(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--event", "COMMENT", "--body", "LGTM"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #42" in result.output
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[])
|
||||
assert "Review posted" in result.output
|
||||
mock_tea.review_pr.assert_called_once_with("owner/repo", 5, event="COMMENT", body="LGTM")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_successful_approve_review(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""APPROVE requires --checklist-confirmed and substantive body."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 7}
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -95,20 +92,19 @@ class TestMain:
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #7" in result.output
|
||||
mock_client.create_review.assert_called_once_with(
|
||||
"5",
|
||||
mock_tea.review_pr.assert_called_once_with(
|
||||
"owner/repo",
|
||||
5,
|
||||
event="APPROVE",
|
||||
body="All 10 checklist categories verified. Architecture OK, tests pass.",
|
||||
comments=[],
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_approve_without_checklist_confirmed_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_approve_without_checklist_confirmed_fails(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""APPROVE without --checklist-confirmed is rejected."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -116,14 +112,14 @@ class TestMain:
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "checklist" in result.output.lower()
|
||||
mock_client.create_review.assert_not_called()
|
||||
mock_tea.review_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_approve_with_trivial_body_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_approve_with_trivial_body_fails(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""APPROVE with trivial body (< 20 chars) and no comments is rejected."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -131,32 +127,30 @@ class TestMain:
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "substantive" in result.output.lower()
|
||||
mock_client.create_review.assert_not_called()
|
||||
mock_tea.review_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None:
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_successful_with_inline_comments(self, mock_tea_cls: MagicMock, tmp_path) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
f = tmp_path / "comments.json"
|
||||
f.write_text(json.dumps(comments))
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 9}
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--comments-json", str(f)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
|
||||
mock_tea.review_pr.assert_called_once_with("owner/repo", 5, event="COMMENT", body="")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None:
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_successful_with_stdin_comments(self, mock_tea_cls: MagicMock) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 11}
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -164,7 +158,7 @@ class TestMain:
|
||||
input=json.dumps(comments),
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
|
||||
mock_tea.review_pr.assert_called_once_with("owner/repo", 5, event="COMMENT", body="")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
@@ -174,44 +168,44 @@ class TestMain:
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_no_body_or_comments_for_comment_event(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "COMMENT"])
|
||||
assert result.exit_code == 1
|
||||
assert "required" in result.output
|
||||
mock_client.create_review.assert_not_called()
|
||||
mock_tea.review_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_no_body_or_comments_for_request_changes(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "REQUEST_CHANGES"])
|
||||
assert result.exit_code == 1
|
||||
assert "required" in result.output
|
||||
mock_client.create_review.assert_not_called()
|
||||
mock_tea.review_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_tea_error_raises_click(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.review_pr.side_effect = TeaCLIError("tea command failed")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--body", "x"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
assert "Failed to post review" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.review_pr.TeaCLI")
|
||||
def test_invalid_event_choice(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "Bogus"])
|
||||
assert result.exit_code != 0
|
||||
mock_client.create_review.assert_not_called()
|
||||
mock_tea.review_pr.assert_not_called()
|
||||
|
||||
@@ -80,6 +80,56 @@ class TestVerify:
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
def test_tea_not_installed(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_no_repo_token(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_already_exists(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "list"], returncode=0, stdout="grm https://git.example.com", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_added_successfully(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=0, stdout="Login added", stderr=""
|
||||
)
|
||||
default_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "default"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result, default_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_add_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=1, stdout="", stderr="auth failed"
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -101,7 +151,8 @@ class TestMain:
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "Setup complete" in result.output
|
||||
|
||||
Reference in New Issue
Block a user