diff --git a/src/qodev_gitlab_cli/app.py b/src/qodev_gitlab_cli/app.py index 09d0d11..86cd047 100644 --- a/src/qodev_gitlab_cli/app.py +++ b/src/qodev_gitlab_cli/app.py @@ -22,6 +22,7 @@ # --------------------------------------------------------------------------- # Import and register command groups # --------------------------------------------------------------------------- +from qodev_gitlab_cli.commands.install import install_app # noqa: E402 from qodev_gitlab_cli.commands.issues import issues_app # noqa: E402 from qodev_gitlab_cli.commands.jobs import jobs_app # noqa: E402 from qodev_gitlab_cli.commands.mrs import mrs_app # noqa: E402 @@ -36,6 +37,8 @@ app.command(_sub) _sub.help_epilogue = "" # prevent epilogue from propagating to sub-command help +app.command(install_app) + from qodev_gitlab_cli.help_reference import build_command_reference # noqa: E402 app.help_epilogue = build_command_reference(_sub_apps) diff --git a/src/qodev_gitlab_cli/commands/install.py b/src/qodev_gitlab_cli/commands/install.py new file mode 100644 index 0000000..8844d5a --- /dev/null +++ b/src/qodev_gitlab_cli/commands/install.py @@ -0,0 +1,59 @@ +"""Install CLI resources (skills for AI agents).""" + +from __future__ import annotations + +import shutil +from importlib.abc import Traversable +from importlib.resources import files +from pathlib import Path +from typing import Annotated + +from cyclopts import App, Parameter + +from qodev_gitlab_cli.output import console + +install_app = App(name="install", help="Install CLI resources.") + + +def _install_skills(target_root: Path | None = None) -> Path: + """Copy bundled skill files to .claude/skills/qodev-gitlab/.""" + root = target_root or Path.cwd() + dest = root / ".claude" / "skills" / "qodev-gitlab" + + source = files("qodev_gitlab_cli") / "skills" + + if dest.exists(): + console.print(f"Replacing existing skills at {dest}") + shutil.rmtree(dest) + dest.mkdir(parents=True) + + _copy_traversable(source, dest) + return dest + + +def _copy_traversable(source: Traversable, dest: Path) -> None: + """Recursively copy from a Traversable (importlib.resources) to a Path.""" + for item in source.iterdir(): + if item.name.startswith("__"): + continue + target = dest / item.name + if item.is_file(): + target.write_bytes(item.read_bytes()) + elif item.is_dir(): + target.mkdir(exist_ok=True) + _copy_traversable(item, target) + + +@install_app.default +def install( + *, + skills: Annotated[bool, Parameter(name="--skills", help="Install AI agent skill files", negative="")] = False, +) -> None: + """Install CLI resources into the current workspace.""" + if not skills: + from qodev_gitlab_cli.output import error + + error("No install target specified. Use: qodev-gitlab install --skills", code="validation", exit_code=83) + + dest = _install_skills() + console.print(f"[green]Installed skills to {dest}[/green]") diff --git a/src/qodev_gitlab_cli/skills/SKILL.md b/src/qodev_gitlab_cli/skills/SKILL.md new file mode 100644 index 0000000..b5a46b3 --- /dev/null +++ b/src/qodev_gitlab_cli/skills/SKILL.md @@ -0,0 +1,147 @@ +# qodev-gitlab CLI + +Agent-friendly CLI for the GitLab API. Designed for AI coding agents with structured JSON output and predictable exit codes. + +## Setup + +```bash +pip install qodev-gitlab-cli +export GITLAB_TOKEN="glpat-..." + +# Install skill files into the current workspace +qodev-gitlab install --skills +``` + +The CLI auto-detects the current GitLab project from the git remote. Override with `--project GROUP/NAME` or `-p GROUP/NAME`. + +## Global Options + +| Flag | Description | +|------|-------------| +| `--json` | Output as JSON (default: rich Markdown) | +| `--project`, `-p` | Project ID or path (default: auto-detected) | +| `--limit` | Results per page (default: 25) | +| `--page` | Page number (default: 1) | +| `--token` | GitLab token (overrides GITLAB_TOKEN) | +| `--url` | GitLab URL (overrides GITLAB_URL) | + +## Command Reference + +### projects + +| Command | Description | +|---------|-------------| +| `projects list [--owned]` | List projects | +| `projects get [ID]` | Get project details (default: current) | + +### mrs (Merge Requests) + +| Command | Description | +|---------|-------------| +| `mrs list [--state STATE]` | List MRs (default: opened) | +| `mrs get IID` | Get MR details | +| `mrs create --title TITLE [--source BRANCH] [--target BRANCH] [--description TEXT] [--labels L] [--squash]` | Create MR | +| `mrs update IID [--title T] [--description D] [--labels L] [--target B]` | Update MR | +| `mrs merge IID [--squash] [--when-pipeline-succeeds]` | Merge MR | +| `mrs close IID` | Close MR | +| `mrs discussions IID` | List MR discussions | +| `mrs changes IID` | Show MR diff | +| `mrs commits IID` | List MR commits | +| `mrs approvals IID` | Show approval status | +| `mrs comment IID --body TEXT` | Comment on MR | +| `mrs pipelines IID` | List MR pipelines | + +### pipelines + +| Command | Description | +|---------|-------------| +| `pipelines list [--ref BRANCH] [--limit N]` | List pipelines | +| `pipelines get ID` | Get pipeline details | +| `pipelines jobs ID` | List pipeline jobs | +| `pipelines wait ID [--timeout S] [--interval S]` | Wait for pipeline to complete | + +### jobs + +| Command | Description | +|---------|-------------| +| `jobs get ID` | Get job details | +| `jobs log ID` | Get job log output | +| `jobs retry ID` | Retry a failed job | + +### issues + +| Command | Description | +|---------|-------------| +| `issues list [--state STATE] [--labels L] [--milestone M]` | List issues | +| `issues get IID` | Get issue details | +| `issues create --title TITLE [--description D] [--labels L]` | Create issue | +| `issues update IID [--title T] [--description D] [--labels L]` | Update issue | +| `issues close IID` | Close issue | +| `issues comment IID --body TEXT` | Comment on issue | +| `issues notes IID` | List issue comments | + +### releases + +| Command | Description | +|---------|-------------| +| `releases list` | List releases | +| `releases get TAG` | Get release details | +| `releases create --tag TAG [--name N] [--description D] [--ref REF]` | Create release | + +### variables + +| Command | Description | +|---------|-------------| +| `variables list` | List CI/CD variables (values hidden) | +| `variables get KEY` | Get a CI/CD variable | +| `variables set KEY VALUE [--protected] [--masked]` | Set a CI/CD variable | + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 80 | Authentication error (bad/missing token) | +| 81 | Not found | +| 82 | API error | +| 83 | Validation error | +| 84 | Configuration error | + +## JSON Output + +All commands support `--json` for structured output. Lists return: + +```json +{"items": [...], "total": 10, "page": 1, "limit": 25} +``` + +Single resources return the raw API object. Errors return: + +```json +{"error": "message", "code": "error_code"} +``` + +## Common Patterns + +```bash +# Get current project info +qodev-gitlab projects get + +# Create MR from current branch +qodev-gitlab mrs create --title "feat: add feature" + +# Check pipeline status as JSON +qodev-gitlab --json pipelines list --limit 5 + +# Wait for pipeline then check result +qodev-gitlab pipelines wait 12345 --timeout 600 + +# Review MR discussions +qodev-gitlab mrs discussions 42 +``` + +## References + +For detailed workflow patterns, see: +- [MR Workflows](references/mr-workflows.md) — Create, review, and merge MRs +- [Pipeline Monitoring](references/pipeline-monitoring.md) — CI/CD monitoring patterns diff --git a/src/qodev_gitlab_cli/skills/__init__.py b/src/qodev_gitlab_cli/skills/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qodev_gitlab_cli/skills/references/__init__.py b/src/qodev_gitlab_cli/skills/references/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/qodev_gitlab_cli/skills/references/mr-workflows.md b/src/qodev_gitlab_cli/skills/references/mr-workflows.md new file mode 100644 index 0000000..ff69060 --- /dev/null +++ b/src/qodev_gitlab_cli/skills/references/mr-workflows.md @@ -0,0 +1,100 @@ +# Merge Request Workflows + +## Create and Submit MR + +```bash +# Create MR from current branch targeting main +qodev-gitlab mrs create --title "feat: add user auth" + +# Create MR with full details +qodev-gitlab mrs create \ + --title "fix: resolve login timeout" \ + --source feature-branch \ + --target main \ + --description "Fixes #42. Increases timeout from 5s to 30s." \ + --labels "bug,priority::high" + +# Create MR with squash enabled +qodev-gitlab mrs create --title "refactor: clean up utils" --squash +``` + +## Review MR + +```bash +# Get MR overview +qodev-gitlab mrs get 42 + +# Check what changed +qodev-gitlab mrs changes 42 + +# Read discussions/review comments +qodev-gitlab mrs discussions 42 + +# Check approval status +qodev-gitlab mrs approvals 42 + +# Check associated pipelines +qodev-gitlab mrs pipelines 42 +``` + +## Update and Respond + +```bash +# Update MR title or description +qodev-gitlab mrs update 42 --title "feat: improved title" +qodev-gitlab mrs update 42 --description "Updated description with more context" + +# Add labels +qodev-gitlab mrs update 42 --labels "reviewed,ready-to-merge" + +# Leave a comment +qodev-gitlab mrs comment 42 --body "Addressed all review comments" +``` + +## Merge + +```bash +# Merge immediately +qodev-gitlab mrs merge 42 + +# Squash and merge +qodev-gitlab mrs merge 42 --squash + +# Merge when pipeline succeeds +qodev-gitlab mrs merge 42 --when-pipeline-succeeds +``` + +## Full Lifecycle Example + +```bash +# 1. Create MR +qodev-gitlab mrs create --title "feat: add caching layer" --labels "enhancement" + +# 2. Check pipeline status +qodev-gitlab mrs pipelines 1 + +# 3. Review feedback +qodev-gitlab mrs discussions 1 + +# 4. Address feedback and comment +qodev-gitlab mrs comment 1 --body "Fixed the race condition in cache invalidation" + +# 5. Check approvals +qodev-gitlab mrs approvals 1 + +# 6. Merge when pipeline passes +qodev-gitlab mrs merge 1 --when-pipeline-succeeds +``` + +## JSON Workflows (for Automation) + +```bash +# Get MR state for conditional logic +STATE=$(qodev-gitlab --json mrs get 42 | jq -r '.state') + +# List all open MRs as JSON +qodev-gitlab --json mrs list --state opened + +# Check if MR has conflicts +qodev-gitlab --json mrs get 42 | jq '.has_conflicts' +``` diff --git a/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md b/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md new file mode 100644 index 0000000..13fbb5f --- /dev/null +++ b/src/qodev_gitlab_cli/skills/references/pipeline-monitoring.md @@ -0,0 +1,74 @@ +# Pipeline Monitoring + +## Check Pipeline Status + +```bash +# List recent pipelines +qodev-gitlab pipelines list + +# List pipelines for a specific branch +qodev-gitlab pipelines list --ref main --limit 5 + +# Get details for a specific pipeline +qodev-gitlab pipelines get 12345 +``` + +## Inspect Pipeline Jobs + +```bash +# List all jobs in a pipeline +qodev-gitlab pipelines jobs 12345 + +# Get details for a specific job +qodev-gitlab jobs get 67890 + +# Read job logs (useful for debugging failures) +qodev-gitlab jobs log 67890 +``` + +## Wait for Pipeline Completion + +```bash +# Wait with default timeout (1 hour) +qodev-gitlab pipelines wait 12345 + +# Wait with custom timeout and check interval +qodev-gitlab pipelines wait 12345 --timeout 600 --interval 30 +``` + +## Handle Failures + +```bash +# 1. Check which jobs failed +qodev-gitlab --json pipelines jobs 12345 | jq '.items[] | select(.status == "failed")' + +# 2. Read the failed job's log +qodev-gitlab jobs log 67890 + +# 3. Retry a failed job +qodev-gitlab jobs retry 67890 +``` + +## MR Pipeline Monitoring + +```bash +# List pipelines associated with an MR +qodev-gitlab mrs pipelines 42 + +# Combine with wait: get latest pipeline ID then wait +PIPELINE_ID=$(qodev-gitlab --json mrs pipelines 42 | jq '.items[0].id') +qodev-gitlab pipelines wait "$PIPELINE_ID" +``` + +## JSON Automation Patterns + +```bash +# Get pipeline status for scripting +STATUS=$(qodev-gitlab --json pipelines get 12345 | jq -r '.status') + +# Count failed jobs +qodev-gitlab --json pipelines jobs 12345 | jq '[.items[] | select(.status == "failed")] | length' + +# Get all job names and statuses +qodev-gitlab --json pipelines jobs 12345 | jq '.items[] | {name, status}' +``` diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..35c9f88 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,62 @@ +"""Tests for the install command.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from qodev_gitlab_cli.commands.install import _install_skills, install + + +class TestInstallSkills: + def test_copies_skill_files(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + assert dest.exists() + assert (dest / "SKILL.md").is_file() + assert (dest / "references" / "mr-workflows.md").is_file() + assert (dest / "references" / "pipeline-monitoring.md").is_file() + + def test_does_not_copy_dunder_files(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + for path in dest.rglob("*"): + assert not path.name.startswith("__"), f"Unexpected dunder file: {path}" + + def test_skill_md_has_content(self, tmp_path: Path) -> None: + _install_skills(target_root=tmp_path) + + skill_md = tmp_path / ".claude" / "skills" / "qodev-gitlab" / "SKILL.md" + content = skill_md.read_text() + assert "qodev-gitlab" in content + assert "Command Reference" in content + + def test_replaces_existing_directory(self, tmp_path: Path) -> None: + dest = tmp_path / ".claude" / "skills" / "qodev-gitlab" + dest.mkdir(parents=True) + stale = dest / "old-file.txt" + stale.write_text("should be removed") + + _install_skills(target_root=tmp_path) + + assert not stale.exists() + assert (dest / "SKILL.md").is_file() + + def test_no_flag_exits_with_validation_error(self) -> None: + with pytest.raises(SystemExit, match="83"): + install(skills=False) + + def test_skills_flag_prints_success(self, tmp_path: Path) -> None: + with ( + patch("qodev_gitlab_cli.commands.install.console") as mock_console, + patch("qodev_gitlab_cli.commands.install.Path") as mock_path, + ): + mock_path.cwd.return_value = tmp_path + install(skills=True) + + calls = [str(c) for c in mock_console.print.call_args_list] + assert any("Installed skills" in c for c in calls)