DEVX-7: fix: make all warnings into errors across devx tools
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
@@ -21,7 +22,7 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
try:
|
||||
body: dict[str, Any] = response.json() if response is not None else {}
|
||||
message: str = body.get("message", str(e))
|
||||
except Exception:
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
message = str(e)
|
||||
return status, message
|
||||
|
||||
|
||||
+18
-14
@@ -114,12 +114,11 @@ def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
def get_vikunja_task_title(task_id: str) -> str:
|
||||
"""Fetch the Vikunja task title for the given DEVX-N identifier.
|
||||
|
||||
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
|
||||
Raises ClickException if the token is set but the task is not found.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
|
||||
"""
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
return ""
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles."))
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
@@ -145,14 +144,10 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
"""Validate that PR title matches the Vikunja task title.
|
||||
|
||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
||||
Raises ClickException if the task is not found or the title doesn't match.
|
||||
Raises ClickException if VIKUNJA_TOKEN is not set, the task is not found,
|
||||
or the title doesn't match.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
# VIKUNJA_TOKEN not set — skip validation (local dev)
|
||||
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
|
||||
return
|
||||
expected = f"{task_id}: {vikunja_title}"
|
||||
if pr_title != expected:
|
||||
raise click.ClickException(
|
||||
@@ -193,7 +188,16 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
# Validate PR number is an integer
|
||||
try:
|
||||
pr_num = int(pr_number)
|
||||
except ValueError:
|
||||
raise click.ClickException(_("PR number must be an integer, got: {pr_number}", pr_number=pr_number)) from None
|
||||
|
||||
# Validate repo format
|
||||
if "/" not in repo:
|
||||
raise click.ClickException(_("Repo must be in 'owner/name' format, got: {repo}", repo=repo))
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
task_id = read_taskid(branch)
|
||||
@@ -210,14 +214,14 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Build merge title: DEVX-N: <conventional commit message>
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
commits = client.get_pr_commits(pr_num)
|
||||
conv_msg = extract_conventional_msg(commits)
|
||||
if not conv_msg:
|
||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
||||
merge_title = f"{task_id}: {conv_msg}"
|
||||
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
@@ -229,7 +233,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
run_cmd(["git", "rebase", "origin/master"])
|
||||
run_cmd(["git", "push", "--force-with-lease", "origin", f"HEAD:{branch}"])
|
||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except (APIError, Exception) as retry_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
@@ -250,7 +254,7 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
||||
pr_number=pr_number,
|
||||
pr_number=pr_num,
|
||||
merge_title=merge_title,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,13 +12,13 @@ Checks performed (all fail with exit code 1 on error):
|
||||
translations file.
|
||||
- **Dead keys**: a key in a translations file is not used in any code.
|
||||
- **Missing languages**: a key exists but is missing one of the 5 supported
|
||||
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
|
||||
languages (en, bg, de, ru, zh). This is an error — all supported languages
|
||||
must have translations for every key.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.check_translations
|
||||
python3 -m devx.ci.check_translations --translations path/to/translations.json
|
||||
python3 -m devx.ci.check_translations --strict # warnings are errors
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -130,14 +130,15 @@ def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> Transla
|
||||
# Check for dead keys (in translations but not used in code)
|
||||
result.dead_keys = result.defined_keys - result.used_keys
|
||||
for key in sorted(result.dead_keys):
|
||||
result.warnings.append(f"Dead key in {name}: {key!r}")
|
||||
result.errors.append(f"Dead key in {name}: {key!r}")
|
||||
|
||||
# Check for missing languages
|
||||
# Check for missing languages — this is an error, not a warning.
|
||||
# All supported languages must have translations for every key.
|
||||
for key, langs in translations.items():
|
||||
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
|
||||
if missing:
|
||||
result.missing_langs[key] = missing
|
||||
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
|
||||
result.errors.append(f"Missing languages {missing} for key {key!r} in {name}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -170,8 +171,7 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
type=click.Path(exists=False, path_type=Path),
|
||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
||||
)
|
||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
||||
def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
@@ -187,25 +187,16 @@ def main(translations: tuple[Path, ...], strict: bool) -> None:
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
has_errors = False
|
||||
has_warnings = False
|
||||
for result in results:
|
||||
print_result(result)
|
||||
if result.errors:
|
||||
has_errors = True
|
||||
if result.warnings:
|
||||
has_warnings = True
|
||||
|
||||
click.echo()
|
||||
if has_errors:
|
||||
click.echo("FAIL: Translation check found errors.", err=True)
|
||||
sys.exit(1)
|
||||
if strict and has_warnings:
|
||||
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
|
||||
sys.exit(1)
|
||||
if has_warnings:
|
||||
click.echo("PASS with warnings: Translation check passed (warnings present).")
|
||||
else:
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
click.echo("PASS: All translations are complete and up to date.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -39,7 +39,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
what we can see. Fallbacks are logged to stderr for debugging.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
@@ -54,8 +54,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
@@ -67,8 +69,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
else:
|
||||
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
@@ -80,8 +84,10 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
elif r.status_code != 403: # 403 is expected without admin scope
|
||||
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
import click
|
||||
@@ -27,25 +27,33 @@ from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("devx")
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via tea CLI. Returns issue index.
|
||||
|
||||
Raises TeaCLIError if tea is not installed or the command fails.
|
||||
Label operations are best-effort — failures are logged but don't
|
||||
prevent issue creation.
|
||||
"""
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
# Check if "bug" label exists
|
||||
# Check if "bug" label exists (best-effort)
|
||||
labels: list[str] = []
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
except TeaCLIError as e:
|
||||
logger.warning("Could not fetch labels (best-effort): %s", e)
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
try:
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
except TeaCLIError as e:
|
||||
logger.warning("Could not add label to issue #%s (best-effort): %s", issue.get("index"), e)
|
||||
return int(issue.get("index", 0))
|
||||
|
||||
|
||||
|
||||
+14
-13
@@ -13,7 +13,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -151,14 +151,16 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
)
|
||||
)
|
||||
return
|
||||
# Non-infrastructure commits without DEVX-N prefix — warn but don't fail
|
||||
click.echo(
|
||||
# Non-infrastructure commits without DEVX-N prefix — this is a
|
||||
# convention violation. Fail the post-merge job so the issue is visible.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Warning: No task ID (DEVX-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
"No task ID ({prefix}-N) found in commit message: {msg}. "
|
||||
"Every non-infrastructure commit must have a task ID.",
|
||||
prefix=TASK_PREFIX,
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
vikunja_task_id = resolve_task_id(client, task_id)
|
||||
@@ -170,19 +172,18 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
# Vikunja is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
# Vikunja API failures must be visible — the task was not updated
|
||||
# and needs manual intervention. Failing the CI job makes this visible.
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
"Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. "
|
||||
"The merge succeeded but the Vikunja task needs manual update.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
)
|
||||
return
|
||||
) from e
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
+13
-2
@@ -86,7 +86,13 @@ def get_bumped_version() -> str:
|
||||
if not version:
|
||||
raise click.ClickException(_("git-cliff returned empty version."))
|
||||
# git-cliff may return with or without 'v' prefix
|
||||
return version.lstrip("v")
|
||||
version = version.lstrip("v")
|
||||
# Validate semver format
|
||||
if not re.match(r"^\d+\.\d+\.\d+$", version):
|
||||
raise click.ClickException(
|
||||
_("git-cliff returned invalid version format: {version}. Expected semver (e.g., 0.4.1).", version=version)
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def get_changelog(new_version: str) -> str:
|
||||
@@ -333,7 +339,12 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Generate changelog
|
||||
changelog = get_changelog(new_version)
|
||||
if not changelog:
|
||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"git-cliff generated empty changelog for v{version}. Check cliff.toml and commit history.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
|
||||
@@ -39,9 +39,20 @@ MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
"""Load the file-to-wiki-page mapping from mapping.json.
|
||||
|
||||
Validates that the mapping is a dict of string-to-string pairs.
|
||||
"""
|
||||
with open(MAPPING_FILE) as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException(
|
||||
_("mapping.json must be a dict of file-path -> page-title, got {type}", type=type(data).__name__)
|
||||
)
|
||||
for k, v in data.items():
|
||||
if not isinstance(k, str) or not isinstance(v, str):
|
||||
raise click.ClickException(_("mapping.json keys and values must be strings, got {k}={v}", k=k, v=v))
|
||||
return data
|
||||
|
||||
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
@@ -239,14 +250,14 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
try:
|
||||
content = read_doc_content(file_path)
|
||||
except FileNotFoundError:
|
||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
|
||||
) from None
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
|
||||
) from None
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
|
||||
@@ -174,6 +174,10 @@ def cli(
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Validate runner index is in range
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
|
||||
@@ -159,8 +159,11 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
if failed_event.is_set():
|
||||
sys.exit(1)
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
parts = pair.split("|")
|
||||
if len(parts) < 2:
|
||||
raise click.ClickException(f"Invalid pair format: {pair!r} (expected at least 2 pipe-delimited parts)")
|
||||
scenario = parts[0]
|
||||
platform_name = parts[1]
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
|
||||
+562
-178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user