Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions src/scitex_dev/_cli/_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
see_also=("{prog} docs — browse doctrine and package documentation",),
)


def _command_to_dict(
cmd: click.Command,
parent_ctx: click.Context | None,
Expand Down Expand Up @@ -96,14 +97,13 @@ def _command_to_dict(
out["commands"] = commands
return out


def _show_recursive_help(ctx: click.Context) -> None:
"""Recursively show help for all commands. Honours ctx.obj['json']."""
if ctx.obj and ctx.obj.get("json"):
import json as _json

tree = _command_to_dict(
ctx.command, ctx.parent, ctx.info_name or "scitex-dev"
)
tree = _command_to_dict(ctx.command, ctx.parent, ctx.info_name or "scitex-dev")
click.echo(_json.dumps(tree, indent=2))
return

Expand Down Expand Up @@ -131,6 +131,7 @@ def _show_recursive_help(ctx: click.Context) -> None:
click.echo(sub_sub_ctx.get_help())
click.echo()


def _get_version() -> str:
try:
from importlib.metadata import version
Expand All @@ -139,6 +140,7 @@ def _get_version() -> str:
except Exception:
return "0.0.0-unknown"


# Disable Click's auto --help on THIS group only (parameter, not
# context — does not propagate to subcommands). Then re-add --help /
# -h explicitly via @click.help_option in the desired display slot so
Expand Down Expand Up @@ -200,6 +202,7 @@ def main(
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())


# -------------------------------------------------------------------
# Ecosystem commands
# -------------------------------------------------------------------
Expand Down Expand Up @@ -229,6 +232,7 @@ def main(
# individual `quality audit-*` callers must update.
from .quality import _check as _cli_quality


# These sub-rules belong inside their canonical owner per the
# consolidation plan. Hidden until folded in (PR-by-PR) so the public
# surface is just five audit-* commands. Removed in 0.11.0.
Expand All @@ -243,17 +247,20 @@ def _ecosystem_audit_docs(projects_root):
"""(deprecated) Splits into `audit-python-apis` (README API drift) and `audit-skills` (SKILL.md drift). Removed in 0.11.0."""
raise SystemExit(_cli_quality.audit_docs(projects_root=projects_root))


@ecosystem_group.command("audit-scope", hidden=True)
@click.option("--projects-root", default=None)
def _ecosystem_audit_scope(projects_root):
"""(deprecated) Folds into `audit-project`. Removed in 0.11.0."""
raise SystemExit(_cli_quality.audit_scope(projects_root=projects_root))


@ecosystem_group.command("audit-lines", hidden=True)
def _ecosystem_audit_lines():
"""(deprecated) Folds into `audit-project` (LOC-limits). Removed in 0.11.0."""
raise SystemExit(_cli_quality.audit_lines())


# Umbrella-only pin freshness audit. Designed to fire from the
# umbrella package's CI; on any other package it exits 0, so it's
# safe to wire into a shared CI step.
Expand All @@ -266,10 +273,12 @@ def _ecosystem_audit_lines():
# CLI-standardization plan). Warn phase forwards; error phase exits 2.
from .._ecosystem.click_compat import deprecated_alias


@main.group("quality", hidden=True)
def _quality_deprecated():
"""(deprecated) Use `scitex-dev ecosystem audit-*` instead."""


for _quality_cmd, _quality_target in (
("audit-docs", _ecosystem_audit_docs),
("audit-scope", _ecosystem_audit_scope),
Expand Down Expand Up @@ -301,9 +310,8 @@ def _quality_deprecated():
# -------------------------------------------------------------------

# `config` → `show-config` rename, error rung of the deprecation ladder.
deprecated_alias(
main, "config", target="show-config", remove_in="0.11", phase="error"
)
deprecated_alias(main, "config", target="show-config", remove_in="0.11", phase="error")


@main.command(
"show-config",
Expand Down Expand Up @@ -357,6 +365,7 @@ def config_cmd(as_json):
else:
click.echo(f" {item}")


# rename-symbols + the hidden `rename` deprecation alias live in
# _cli/_rename.py. Extracted to keep _root.py under the line budget
# and to give the bulk-rename surface a focused module to grow into.
Expand Down Expand Up @@ -389,6 +398,7 @@ def config_cmd(as_json):
docs_grp = docs_click_group(package="scitex-dev")
main.add_command(docs_grp)


# `docs search` — canonical home for ecosystem-wide search across APIs,
# CLI, MCP tools, and documentation. The legacy top-level `search-docs`
# is kept as a hidden deprecation alias (see below). Removed in 0.11.0.
Expand All @@ -399,15 +409,17 @@ def config_cmd(as_json):
summary="Search across APIs, CLI, MCP tools, and documentation.",
examples=(
Example('{prog} docs search "save figure"', "Full-text search everywhere."),
Example("{prog} docs search version --scope api", "Limit to the API scope."),
Example("{prog} docs search hpc --max-results 20 --json", "More hits, as JSON."),
Example(
"{prog} docs search version --scope api", "Limit to the API scope."
),
Example(
"{prog} docs search hpc --max-results 20 --json", "More hits, as JSON."
),
),
),
)
@click.argument("query")
@click.option(
"--scope", default="all", help="Search scope: all, api, cli, mcp, docs."
)
@click.option("--scope", default="all", help="Search scope: all, api, cli, mcp, docs.")
@click.option("--max-results", default=10, help="Maximum results.")
@click.option("--json", "as_json", is_flag=True, help="Output as structured JSON.")
def _docs_search(query, scope, max_results, as_json):
Expand All @@ -422,6 +434,7 @@ def _docs_search(query, scope, max_results, as_json):
max_results=max_results,
)


from .skills._manage import register_skills_commands

register_skills_commands(main)
Expand Down Expand Up @@ -459,12 +472,17 @@ def _docs_search(query, scope, max_results, as_json):
register_integration_commands(main)

# -------------------------------------------------------------------
# ci runner — self-hosted GitHub Actions runner lifecycle
# ci — self-hosted runner lifecycle (`ci runner`) + CI-failure reading
# (`ci why`), both attached to the one top-level `ci` group.
# -------------------------------------------------------------------

from ..ci.runner import register_ci_runner_commands

register_ci_runner_commands(main)
ci_group = register_ci_runner_commands(main)

from ..ci._why_cli import register_ci_why_command

register_ci_why_command(ci_group)

# -------------------------------------------------------------------
# linter — engine moved here from scitex-linter (soft migration)
Expand Down
101 changes: 101 additions & 0 deletions src/scitex_dev/ci/_why_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``scitex-dev ci why`` — read WHY a CI run is red, cheaply.

The thin CLI verb over the CI-failure-reading primitive
(``scitex_dev.ci.why``), attached to the top-level ``ci`` group alongside
``ci runner``. Reading CI *status* is one word (``failure``); this reads
the *reason* for a fraction of the log by fetching a failing run's
``--log-failed`` ONCE and distilling it to failing test ids, assertion
lines, or a setup ``##[error]``.

``why`` resolves a PR number / run id / branch / nothing (current branch)
to the failing run(s) behind it. A target it cannot read raises loudly
(exit 2) — UNKNOWN never reads as green. Exit is tri-state: 0 green, 1
red (the reason is printed), 2 could-not-read.
"""

from __future__ import annotations

import json as _json
import sys

import click

from .._ecosystem.help_spec import CliHelp, Example, SpecCommand

_WHY_HELP = CliHelp(
summary="Extract the real reason a CI run is red — as cheaply as status.",
description=(
"TARGET is a PR number, a run id (>=8 digits), a branch name, or "
"omitted (the current git branch's latest run). Fetches each "
"failing run's log once and distils it to failing test ids, "
"assertion lines, or a setup `##[error]` — a few hundred bytes, "
"not the whole log.",
),
examples=(
Example("{prog} ci why 712", "Explain PR 712's failing checks."),
Example("{prog} ci why 29446283736", "Explain one run id."),
Example("{prog} ci why -R owner/repo main", "A branch of a repo."),
Example("{prog} ci why 712 --json", "Structured output."),
),
exit_codes=(
(0, "no failing jobs found (green)"),
(1, "failing jobs found — the distilled reason is printed"),
(2, "could not read the run (gh missing/unauthenticated/unresolved)"),
),
)


def _emit_json(runs) -> None:
click.echo(_json.dumps([r.to_dict() for r in runs], indent=2, default=str))


def _emit_human(runs) -> None:
from .why import render_text

blocks: list[str] = []
for run in runs:
head = f"run {run.run_id}"
if run.workflow:
head += f": {run.workflow}"
if run.branch:
head += f" [{run.branch}]"
block = [head, render_text(run)]
if run.url:
block.append(f"-> {run.url}")
blocks.append("\n".join(block))
click.echo("\n\n".join(blocks))


def register_ci_why_command(ci_group):
"""Attach the ``why`` verb to the top-level ``ci`` group (beside runner)."""

@ci_group.command("why", cls=SpecCommand, help_spec=_WHY_HELP)
@click.argument("target", required=False, default="")
@click.option("-R", "--repo", default=None, help="Target OWNER/REPO (else CWD).")
@click.option("--json", "as_json", is_flag=True, help="Emit structured JSON.")
def ci_why(target, repo, as_json):
from .why import CIWhyError, explain_ci_run

try:
runs = explain_ci_run(target or None, repo=repo)
except CIWhyError as exc:
click.echo(f"ci why: {exc}", err=True)
sys.exit(2)

if as_json:
_emit_json(runs)
else:
_emit_human(runs)

any_failures = any(run.failures for run in runs)
sys.exit(1 if any_failures else 0)

return ci_why


__all__ = ["register_ci_why_command"]


# EOF
1 change: 1 addition & 0 deletions src/scitex_dev/ci/runner/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def ci(ctx: click.Context) -> None:
\b
Verbs:
runner — self-hosted GitHub Actions runner lifecycle
why — read WHY a CI run is red (distil the failure log)
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
Expand Down
55 changes: 55 additions & 0 deletions src/scitex_dev/ci/why/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""``scitex_dev.ci.why`` — the ecosystem CI-failure-reading primitive.

Reading CI *status* is one word (``failure``); reading *why* has been
tens of thousands of lines, so a bounded-context agent is steered to the
cheap word and the word replaces the reason instead of summarising it.
This primitive inverts that price: it fetches a failing run's log ONCE
and distils it to a few hundred bytes — failing test ids, assertion
lines, or a setup failure's ``##[error]``.

The reusable SSOT any SciTeX project (sac, the umbrella, …) consumes via
a thin ``ci why`` verb. Everything except the injectable :func:`run_gh`
gh-seam is pure/string-based (unit-tested, no network); a target that
cannot be read raises :class:`CIWhyError` (UNKNOWN, never a silent
"green").

Public entry::

from scitex_dev.ci.why import explain_ci_run, render_text

for run in explain_ci_run("712"): # PR#, run id, or branch
print(render_text(run))
"""

from __future__ import annotations

from ._model import CIWhyError, GhRunner, JobFailure, RunFailures
from ._parse import (
clean_log_line,
parse_failed_log,
parse_job_context,
split_log_by_job,
)
from ._resolve import (
explain_ci_run,
explain_run,
render_text,
resolve_run_ids,
run_gh,
)

__all__ = [
"CIWhyError",
"GhRunner",
"JobFailure",
"RunFailures",
"clean_log_line",
"split_log_by_job",
"parse_job_context",
"parse_failed_log",
"run_gh",
"resolve_run_ids",
"explain_run",
"explain_ci_run",
"render_text",
]
Loading
Loading