Skip to content
Merged
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
6 changes: 5 additions & 1 deletion skills/smith/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ Full vocabulary and flags live in `references/usage-recipes.md`. The minimum you
| Discovery | `smith <azdo-remote-name> orgs`, `smith <github-remote-name> orgs`, `smith <gitlab-remote-name> groups`, `smith <azdo-remote-name> repos <project>`, `smith <github-remote-name> repos`, `smith <gitlab-remote-name> repos` |
| Focused grep | `smith <azdo-remote-name> code grep <project> <repo> "<regex>"`, `smith <github-remote-name> code grep <repo> "<regex>"`, `smith <gitlab-remote-name> code grep <group/project> "<regex>"` |
| PRs / MRs | `smith <azdo-remote-name> prs search`, `smith <github-remote-name> prs search`, `smith <gitlab-remote-name> prs search`, `smith <github-remote-name> prs list <repo>`, `smith <gitlab-remote-name> prs list <group/project>` |
| Pipelines | `smith <github-remote-name> pipelines list <repo> <id>`, `smith <gitlab-remote-name> pipelines list <group/project> <id>`, `smith <github-remote-name> pipelines grep <repo> <id> "<regex>"`, `smith <gitlab-remote-name> pipelines grep <group/project> <id> "<regex>"` |
| Pipelines | `smith <github-remote-name> pipelines list <repo> <id>`, `smith <gitlab-remote-name> pipelines list <group/project> <id>`, `smith <github-remote-name> pipelines grep <repo> <id> "<regex>"`, `smith <gitlab-remote-name> pipelines grep <group/project> <id> "<regex>"`, `smith <gitlab-remote-name> pipelines artifacts list <group/project> <pipeline-id> <job-id>`, `smith <gitlab-remote-name> pipelines artifacts grep <group/project> <pipeline-id> <job-id> "<regex>"` |
| Stories / Issues | `smith <azdo-remote-name> stories search <project> --query`, `smith <gitlab-remote-name> stories search <group/project> --query`, `smith <youtrack-remote-name> stories search --query` |

All grep commands (code, pipeline logs, artifacts) support: `--path`, `--glob`, `--output-mode` (content/files_with_matches/count), `--context-lines`, `--from-line`/`--to-line`, `--reverse`, `--case-sensitive`. Code grep adds: `--branch`, `--no-clone`. Pipeline grep adds: `--log-id`.

Rules that save retries:

- **GitHub**: repo arg is bare `<repo>`, not `org/repo`. Search output may look like `org/repo:path` but commands still take `<repo>`.
Expand All @@ -51,6 +53,7 @@ Rules that save retries:
- **YouTrack**: no repo arg; only issue IDs (e.g. `RAD-1055`) and `--query`.
- Global `smith code search` and `smith prs search` target every enabled remote and reject `--project` or `--repo`. Use `smith <remote> ...` to narrow.
- `pipelines grep ... <id>` expects a pipeline/run/build ID. For a specific job or log, call `pipelines list ...` first to find the parent ID, then `pipelines grep ... <pipeline-id> ".*" --log-id <job-or-log-id>`.
- `pipelines artifacts ... <pipeline-id> <job-id>` is GitLab-only. Use `artifacts list` to enumerate archive paths and `artifacts grep`
- `pipelines list ... <id>` prints a compact DAG (`@` pipelines, `#` stages, `*` jobs, inline `<needs` and `>>` downstream). GitLab traverses child pipelines via GraphQL (REST fallback emits header-only rows with a warning). Filter with `--status`, `--grep`, `--skip`/`--take`, `--max-depth` (gitlab only, default 0 = unlimited). Full grammar lives in `references/pipelines-format.md`.

Use `--help` on any command for flags.
Expand All @@ -67,6 +70,7 @@ Use `--help` on any command for flags.
### Pipeline Analysis
1. Use `smith pipelines list <repo> <pipeline_id> --status failed` to focus on failed jobs.
2. Once you know the pipeline log ID, use `smith pipelines grep <repo> <pipeline_id> <log_id> --reverse` to analyze the logs.
3. For GitLab artifact-backed failures, use `smith <gitlab-remote-name> pipelines artifacts list <group/project> <pipeline_id> <job_id>` and then `... artifacts grep ... "<regex>"`.
## Stop Conditions

Stop narrowing and answer when any of these is true:
Expand Down
41 changes: 41 additions & 0 deletions src/smith/cli/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,47 @@ def handle_ci_grep(client: SmithClient, args: argparse.Namespace) -> int:
)


def handle_ci_artifacts_list(client: SmithClient, args: argparse.Namespace) -> int:
data = client.execute_ci_artifacts_list(
remote_or_provider=_selected_target(args),
project=getattr(args, "project", None),
repo=getattr(args, "repo", None),
pipeline_id=args.id,
job_id=args.job_id,
)
return _emit_success(
args=args,
command=args.command_id,
data=data,
partial=_is_partial_result(data),
)


def handle_ci_artifacts_grep(client: SmithClient, args: argparse.Namespace) -> int:
data = client.execute_ci_artifacts_grep(
remote_or_provider=_selected_target(args),
project=getattr(args, "project", None),
repo=getattr(args, "repo", None),
pipeline_id=args.id,
job_id=args.job_id,
pattern=args.pattern,
path=getattr(args, "path", None),
glob=getattr(args, "glob", None),
output_mode=args.output_mode,
case_insensitive=not args.case_sensitive,
context_lines=args.context_lines,
from_line=args.from_line,
to_line=args.to_line,
reverse=getattr(args, "reverse", False),
)
return _emit_success(
args=args,
command=args.command_id,
data=data,
partial=_is_partial_result(data),
)


def handle_work_get(client: SmithClient, args: argparse.Namespace) -> int:
request_kwargs: dict[str, Any] = {
"remote_or_provider": _selected_target(args),
Expand Down
78 changes: 78 additions & 0 deletions src/smith/cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from smith.cli.handlers import (
_csv_list,
handle_cache_clean,
handle_ci_artifacts_grep,
handle_ci_artifacts_list,
handle_ci_grep,
handle_ci_list,
handle_code_grep,
Expand Down Expand Up @@ -183,6 +185,32 @@ def _add_ci_grep_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--case-sensitive", action="store_true")


def _add_artifact_grep_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--path", help="Artifact path scope within the extracted archive")
parser.add_argument("--glob", help="Artifact filename glob filter")
parser.add_argument(
"pattern",
help=(
'Regex pattern. Use ".*" to match all. '
'Form: smith <remote> pipelines artifacts grep <scope> <pipeline_id> <job_id> "<regex>"'
),
)
parser.add_argument(
"--output-mode",
choices=["content", "files_with_matches", "count"],
default="content",
)
parser.add_argument("--context-lines", type=int, default=3)
parser.add_argument("--from-line", type=int)
parser.add_argument("--to-line", type=int)
parser.add_argument(
"--reverse",
action="store_true",
help="Emit matches in reverse order so the most recent hits appear first.",
)
parser.add_argument("--case-sensitive", action="store_true")


def _add_work_search_filters(parser: argparse.ArgumentParser, *, include_area: bool = True) -> None:
if include_area:
parser.add_argument("--area")
Expand Down Expand Up @@ -549,6 +577,56 @@ def _add_remote_pipelines_group(remote_subparsers: Any, *, remote: RemoteConfig)
_add_output_format(pipelines_grep)
_set_handler(pipelines_grep, handle_ci_grep, "pipelines.grep", primary_path="pipelines grep")

if remote.provider == "gitlab":
pipelines_artifacts = _add_parser(
pipelines_sub,
"artifacts",
help_text="List and grep GitLab job artifacts",
)
pipelines_artifacts_sub = pipelines_artifacts.add_subparsers(
dest="pipelines_artifacts_action",
required=True,
)

pipelines_artifacts_list = _add_parser(
pipelines_artifacts_sub,
"list",
help_text="List artifact paths for a job",
)
_add_pipeline_positional_args(
pipelines_artifacts_list,
remote=remote,
id_label=id_label,
)
pipelines_artifacts_list.add_argument("job_id", type=int, help="Job ID")
_add_output_format(pipelines_artifacts_list)
_set_handler(
pipelines_artifacts_list,
handle_ci_artifacts_list,
"pipelines.artifacts.list",
primary_path="pipelines artifacts list",
)

pipelines_artifacts_grep = _add_parser(
pipelines_artifacts_sub,
"grep",
help_text="Search extracted GitLab job artifacts",
)
_add_pipeline_positional_args(
pipelines_artifacts_grep,
remote=remote,
id_label=id_label,
)
pipelines_artifacts_grep.add_argument("job_id", type=int, help="Job ID")
_add_artifact_grep_options(pipelines_artifacts_grep)
_add_output_format(pipelines_artifacts_grep)
_set_handler(
pipelines_artifacts_grep,
handle_ci_artifacts_grep,
"pipelines.artifacts.grep",
primary_path="pipelines artifacts grep",
)


def _add_remote_stories_group(remote_subparsers: Any, *, remote: RemoteConfig) -> None:
stories = _add_parser(remote_subparsers, "stories", help_text="Get, search, and get mine")
Expand Down
77 changes: 77 additions & 0 deletions src/smith/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ def _require_single_target(remote_or_provider: str, *, command: str) -> str:
raise ValueError(f"{command} does not support target 'all'. Use a configured remote name.")
return target

def _require_gitlab_target(self, remote_or_provider: str, *, command: str) -> str:
target = self._require_single_target(remote_or_provider, command=command)
remotes = self._resolve_remotes(target)
if not remotes:
raise ValueError(f"No enabled remote found for '{target}'")
if len(remotes) != 1 or remotes[0].provider != "gitlab":
raise ValueError(f"{command} is only supported for GitLab remotes.")
return target

def _get_provider_for_remote(self, remote: RemoteConfig) -> BaseProvider:
if remote.name in self._provider_cache:
return self._provider_cache[remote.name]
Expand Down Expand Up @@ -725,6 +734,74 @@ def execute_ci_grep(
},
)

def execute_ci_artifacts_list(
self,
*,
remote_or_provider: str,
project: str | None,
repo: str | None,
pipeline_id: int,
job_id: int,
) -> dict[str, Any]:
target = self._require_gitlab_target(
remote_or_provider,
command="pipelines.artifacts.list",
)
effective_repo = repo or project
return self._fanout(
remote_or_provider=target,
operations={
"gitlab": lambda r: self._gitlab_provider(r).list_job_artifacts(
repo=str(effective_repo),
pipeline_id=pipeline_id,
job_id=job_id,
),
},
)

def execute_ci_artifacts_grep(
self,
*,
remote_or_provider: str,
project: str | None,
repo: str | None,
pipeline_id: int,
job_id: int,
pattern: str | None,
path: str | None,
glob: str | None,
output_mode: Literal["content", "files_with_matches", "count"],
case_insensitive: bool,
context_lines: int | None,
from_line: int | None,
to_line: int | None,
reverse: bool = False,
) -> dict[str, Any]:
target = self._require_gitlab_target(
remote_or_provider,
command="pipelines.artifacts.grep",
)
effective_repo = repo or project
return self._fanout(
remote_or_provider=target,
operations={
"gitlab": lambda r: self._gitlab_provider(r).grep_job_artifacts(
repo=str(effective_repo),
pipeline_id=pipeline_id,
job_id=job_id,
pattern=pattern,
path=path,
glob=glob,
output_mode=output_mode,
case_insensitive=case_insensitive,
context_lines=context_lines,
from_line=from_line,
to_line=to_line,
reverse=reverse,
),
},
)

def execute_work_get(
self,
*,
Expand Down
11 changes: 9 additions & 2 deletions src/smith/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ def _render_grep(data: Any) -> str:
return text


def _render_artifacts_list(data: Any) -> str:
paths = data.get("paths", []) if isinstance(data, dict) else []
return "\n".join(str(path) for path in paths if str(path).strip())


def _render_cache_clean(data: Any) -> str:
cleaned = data.get("cleaned", []) if isinstance(data, dict) else []
missing = data.get("missing", []) if isinstance(data, dict) else []
Expand Down Expand Up @@ -758,6 +763,8 @@ def _render_needs(needs: Any, *, name_to_id: dict[str, Any]) -> str:
"cache.clean": _render_cache_clean,
"pipelines.list": _render_pipelines_list,
"pipelines.grep": _render_grep,
"pipelines.artifacts.list": _render_artifacts_list,
"pipelines.artifacts.grep": _render_grep,
"prs.search": _render_pr_list,
"prs.list": _render_pr_list,
"prs.get": _render_pr_get,
Expand Down Expand Up @@ -805,7 +812,7 @@ def _render_remote_grouped(command: str, payload: dict[str, Any]) -> str:
lines.append(rendered)

warnings = _visible_remote_warnings(command, remote_data, entry.get("warnings") or [])
if command not in {"code.grep", "pipelines.grep"}:
if command not in {"code.grep", "pipelines.grep", "pipelines.artifacts.grep"}:
for warning in warnings:
lines.append(f"warning: {warning}")
return "\n".join(lines).rstrip()
Expand Down Expand Up @@ -851,7 +858,7 @@ def _render_remote_grouped(command: str, payload: dict[str, Any]) -> str:
output_lines.append(rendered)

warnings = _visible_remote_warnings(command, remote_data, entry.get("warnings") or [])
if command not in {"code.grep", "pipelines.grep"}:
if command not in {"code.grep", "pipelines.grep", "pipelines.artifacts.grep"}:
for warning in warnings:
output_lines.append(f"warning: {warning}")
output_lines.append("")
Expand Down
Loading
Loading