From 6f113115cd0c4d382139f2ca3165064f7d062e3c Mon Sep 17 00:00:00 2001 From: Richard West Date: Fri, 6 Mar 2026 17:21:00 -0800 Subject: [PATCH 1/2] feat: add JSON support to ScriptIssueEngine with legacy fallback --- src/git_graphable/issues/script.py | 19 ++++++++++++++----- tests/issues/test_script.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/git_graphable/issues/script.py b/src/git_graphable/issues/script.py index f279f5c..1439ebf 100644 --- a/src/git_graphable/issues/script.py +++ b/src/git_graphable/issues/script.py @@ -2,6 +2,7 @@ Script-based issue tracker integration. """ +import json import shlex import subprocess from typing import Dict, List @@ -27,11 +28,19 @@ def get_issue_info(self, issue_ids: List[str]) -> Dict[str, IssueInfo]: cmd_str, shell=True, capture_output=True, text=True, check=True ) output = result.stdout.strip() - # Simple parsing logic for script output: "STATUS,ASSIGNEE,CREATED_AT" - parts = output.split(",") - raw_status = parts[0].upper() - assignee = parts[1].strip() if len(parts) > 1 else None - created_at = parts[2].strip() if len(parts) > 2 else None + + # Try JSON parsing first + try: + data = json.loads(output) + raw_status = data.get("status", "").upper() + assignee = data.get("assignee") + created_at = data.get("created_at") + except json.JSONDecodeError: + # Fallback to simple parsing logic: "STATUS,ASSIGNEE,CREATED_AT" + parts = output.split(",") + raw_status = parts[0].upper() + assignee = parts[1].strip() if len(parts) > 1 else None + created_at = parts[2].strip() if len(parts) > 2 else None status = IssueStatus.UNKNOWN if "OPEN" in raw_status: diff --git a/tests/issues/test_script.py b/tests/issues/test_script.py index 39bf440..403b9f9 100644 --- a/tests/issues/test_script.py +++ b/tests/issues/test_script.py @@ -42,6 +42,25 @@ def test_script_engine_full_info(): assert info.created_at == "2023-01-01T12:00:00Z" +def test_script_engine_json_success(): + """Test JSON info parsing.""" + import json + + with patch("subprocess.run") as mock_run: + mock_data = { + "status": "OPEN", + "assignee": "Bob", + "created_at": "2023-02-02T10:00:00Z", + } + mock_run.return_value = MagicMock(stdout=json.dumps(mock_data), returncode=0) + engine = ScriptIssueEngine(script_template="check {id}") + info_map = engine.get_issue_info(["456"]) + info = info_map["456"] + assert info.status == IssueStatus.OPEN + assert info.assignee == "Bob" + assert info.created_at == "2023-02-02T10:00:00Z" + + def test_script_engine_failure(): """Verify handling of script failures.""" with patch("subprocess.run") as mock_run: From 9712748c4a1dc30d9f443f3b9a03d332a3d6d24b Mon Sep 17 00:00:00 2001 From: Richard West Date: Fri, 6 Mar 2026 17:21:07 -0800 Subject: [PATCH 2/2] test: increase coverage for commands.py and bare_cli.py --- CHANGELOG.md | 2 + USAGE.md | 6 ++- src/git_graphable/issues/__init__.py | 3 ++ src/git_graphable/issues/gitlab.py | 40 ++++++++++++++ src/git_graphable/prs/__init__.py | 3 ++ src/git_graphable/prs/gitlab.py | 54 +++++++++++++++++++ tests/issues/test_gitlab.py | 25 +++++++++ tests/prs/test_gitlab.py | 38 +++++++++++++ tests/test_bare_cli.py | 79 +++++++++++++++++++--------- tests/test_commands.py | 56 +++++++++++++++++++- 10 files changed, 277 insertions(+), 29 deletions(-) create mode 100644 src/git_graphable/issues/gitlab.py create mode 100644 src/git_graphable/prs/gitlab.py create mode 100644 tests/issues/test_gitlab.py create mode 100644 tests/prs/test_gitlab.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c626308..b1fe5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [0.6.0] - 2026-03-06 ### Added +- **Native GitLab Support**: Added support for GitLab Merge Requests and Issues using the `glab` CLI. - **Pull Request Provider Abstraction**: Introduced a new `PullRequestProvider` interface, decoupling PR status logic from GitHub. - Added `GitHubPullRequestProvider` (default) using the `gh` CLI. - Added `ScriptPullRequestProvider` for custom script-based PR status lookups. @@ -13,6 +14,7 @@ All notable changes to this project will be documented in this file. - `src/git_graphable/issues/`: Modular issue tracker engines. - `src/git_graphable/highlights/`: Specialized highlighting logic (visual, hygiene, external). - `src/git_graphable/styling/`: Specialized styling logic per visualization engine. +- **Enhanced Issue Scripting**: `ScriptIssueEngine` now supports robust JSON output from scripts (with legacy CSV fallback). - **Reorganized Test Suite**: Refactored tests into a modular structure mirroring the source code, including a global `conftest.py` for shared fixtures. ### Fixed diff --git a/USAGE.md b/USAGE.md index 207fd24..259ea02 100644 --- a/USAGE.md +++ b/USAGE.md @@ -50,7 +50,9 @@ Analyze git history and generate a graph. This is the default command; if no com * `--highlight-long-running`: Highlight long-running branches * `--long-running-days INTEGER`: Threshold in days for long-running branches * `--long-running-base TEXT`: Base branch for long-running analysis -* `--highlight-pr-status`: Highlight commits based on GitHub PR status +* `--highlight-pr-status`: Highlight commits based on PR status (GitHub or GitLab) +* `--pr-provider [github|gitlab|script]`: Provider to fetch PR statuses (default: github) +* `--pr-script TEXT`: Path to a script that returns PR data in JSON format * `--highlight-wip`: Highlight WIP/TODO commits * `--wip-keyword TEXT`: Additional keyword to trigger WIP highlighting * `--highlight-direct-pushes`: Highlight non-merge commits on protected branches @@ -61,7 +63,7 @@ Analyze git history and generate a graph. This is the default command; if no com * `--silo-author-count INTEGER`: Author count threshold for silo detection * `--highlight-issue-inconsistencies`: Highlight mismatches between Git and Issue Tracker * `--issue-pattern TEXT`: Regex pattern to extract issue IDs -* `--issue-engine [github|jira|script]`: Engine to fetch issue statuses +* `--issue-engine [github|gitlab|jira|script]`: Engine to fetch issue statuses * `--jira-url TEXT`: Base URL for Jira instance * `--issue-script TEXT`: Shell command template for script engine * `--highlight-release-inconsistencies`: Highlight issues marked Released but not tagged diff --git a/src/git_graphable/issues/__init__.py b/src/git_graphable/issues/__init__.py index f65590a..a293456 100644 --- a/src/git_graphable/issues/__init__.py +++ b/src/git_graphable/issues/__init__.py @@ -6,6 +6,7 @@ from .base import IssueInfo, IssueStatus, IssueTracker from .github import GitHubIssueEngine +from .gitlab import GitLabIssueEngine from .jira import JiraIssueEngine from .script import ScriptIssueEngine @@ -15,6 +16,8 @@ def get_issue_engine(config: Any) -> Optional[IssueTracker]: engine_type = (getattr(config, "issue_engine", "") or "").lower() if engine_type == "github": return GitHubIssueEngine() + elif engine_type == "gitlab": + return GitLabIssueEngine() elif engine_type == "jira": return JiraIssueEngine( url=getattr(config, "jira_url", "") or "", diff --git a/src/git_graphable/issues/gitlab.py b/src/git_graphable/issues/gitlab.py new file mode 100644 index 0000000..573028d --- /dev/null +++ b/src/git_graphable/issues/gitlab.py @@ -0,0 +1,40 @@ +""" +GitLab issue tracker integration using glab CLI. +""" + +import json +import subprocess +from typing import Dict, List + +from .base import IssueInfo, IssueStatus, IssueTracker + + +class GitLabIssueEngine(IssueTracker): + """GitLab Issues integration using 'glab' CLI.""" + + def get_issue_info(self, issue_ids: List[str]) -> Dict[str, IssueInfo]: + results = {} + for issue_id in issue_ids: + try: + # We assume issue_id is the iid (number) + cmd = ["glab", "issue", "view", issue_id, "-F", "json"] + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + + raw_state = data.get("state", "").upper() + status = IssueStatus.UNKNOWN + if raw_state == "OPENED": + status = IssueStatus.OPEN + elif raw_state == "CLOSED": + status = IssueStatus.CLOSED + + assignees = data.get("assignees", []) + assignee = assignees[0].get("username") if assignees else None + created_at = data.get("created_at") + + results[issue_id] = IssueInfo( + id=issue_id, status=status, assignee=assignee, created_at=created_at + ) + except Exception: + results[issue_id] = IssueInfo(id=issue_id, status=IssueStatus.UNKNOWN) + return results diff --git a/src/git_graphable/prs/__init__.py b/src/git_graphable/prs/__init__.py index 003ec9b..80184f5 100644 --- a/src/git_graphable/prs/__init__.py +++ b/src/git_graphable/prs/__init__.py @@ -6,6 +6,7 @@ from .base import PullRequestInfo, PullRequestProvider from .github import GitHubPullRequestProvider +from .gitlab import GitLabPullRequestProvider from .script import ScriptPullRequestProvider @@ -16,6 +17,8 @@ def get_pr_provider(config: Any) -> Optional[PullRequestProvider]: if provider_type == "github": return GitHubPullRequestProvider() + elif provider_type == "gitlab": + return GitLabPullRequestProvider() elif provider_type == "script": return ScriptPullRequestProvider( script_path=getattr(config, "pr_script", "") or "" diff --git a/src/git_graphable/prs/gitlab.py b/src/git_graphable/prs/gitlab.py new file mode 100644 index 0000000..df628e0 --- /dev/null +++ b/src/git_graphable/prs/gitlab.py @@ -0,0 +1,54 @@ +""" +GitLab Merge Request provider using glab CLI. +""" + +import json +import subprocess +from typing import List + +from .base import PullRequestInfo, PullRequestProvider + + +class GitLabPullRequestProvider(PullRequestProvider): + """GitLab MR integration using 'glab' CLI.""" + + def get_repo_prs(self, repo_path: str) -> List[PullRequestInfo]: + """Fetch all MRs for the repository using glab CLI.""" + try: + # Check if glab is installed + subprocess.run(["glab", "--version"], capture_output=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + return [] + + try: + # Fetch MRs + # -F json returns full MR objects + cmd = ["glab", "mr", "list", "--all", "-F", "json"] + result = subprocess.run( + cmd, cwd=repo_path, capture_output=True, text=True, check=True + ) + data = json.loads(result.stdout) + + prs = [] + for item in data: + # glab uses 'opened', 'closed', 'merged' + state = item["state"].upper() + if state == "OPENED": + state = "OPEN" + + # glab has 'sha' for head and 'merge_commit_sha' + prs.append( + PullRequestInfo( + number=item["iid"], + title=item["title"], + state=state, + is_draft=item.get("draft", False) or item.get("work_in_progress", False), + head_ref_name=item["source_branch"], + head_ref_oid=item["sha"], + merge_commit_oid=item.get("merge_commit_sha"), + mergeable=item.get("merge_status", "can_be_merged").upper(), + ) + ) + return prs + except Exception: + return [] diff --git a/tests/issues/test_gitlab.py b/tests/issues/test_gitlab.py new file mode 100644 index 0000000..3d27aac --- /dev/null +++ b/tests/issues/test_gitlab.py @@ -0,0 +1,25 @@ +""" +Tests for GitLab issue tracker. +""" + +import json +from unittest.mock import MagicMock, patch + +from git_graphable.issues.base import IssueStatus +from git_graphable.issues.gitlab import GitLabIssueEngine + + +def test_gitlab_issue_engine_success(): + mock_data = { + "state": "opened", + "assignees": [{"username": "gitlab_user"}], + "created_at": "2023-01-01T00:00:00Z", + } + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout=json.dumps(mock_data), returncode=0) + engine = GitLabIssueEngine() + info_map = engine.get_issue_info(["101"]) + info = info_map["101"] + assert info.status == IssueStatus.OPEN + assert info.assignee == "gitlab_user" + assert info.created_at == "2023-01-01T00:00:00Z" diff --git a/tests/prs/test_gitlab.py b/tests/prs/test_gitlab.py new file mode 100644 index 0000000..fb8ebe3 --- /dev/null +++ b/tests/prs/test_gitlab.py @@ -0,0 +1,38 @@ +""" +Tests for GitLab PR provider. +""" + +import json +from unittest.mock import MagicMock, patch + +from git_graphable.prs.gitlab import GitLabPullRequestProvider + + +def test_gitlab_pr_provider_success(): + mock_stdout = json.dumps( + [ + { + "iid": 42, + "title": "GitLab MR", + "state": "opened", + "draft": True, + "source_branch": "feat/gitlab", + "sha": "sha123", + "merge_commit_sha": "sha456", + "merge_status": "can_be_merged", + } + ] + ) + with patch("subprocess.run") as mock_run: + # First call for glab version check, second for mr list + mock_run.side_effect = [ + MagicMock(returncode=0), + MagicMock(stdout=mock_stdout, returncode=0), + ] + provider = GitLabPullRequestProvider() + prs = provider.get_repo_prs(".") + assert len(prs) == 1 + assert prs[0].number == 42 + assert prs[0].state == "OPEN" + assert prs[0].is_draft is True + assert prs[0].merge_commit_oid == "sha456" diff --git a/tests/test_bare_cli.py b/tests/test_bare_cli.py index 9ceefa3..5a7e9e5 100644 --- a/tests/test_bare_cli.py +++ b/tests/test_bare_cli.py @@ -1,5 +1,4 @@ import os -import shutil import subprocess import tempfile from unittest.mock import patch @@ -9,30 +8,6 @@ from git_graphable.bare_cli import run_bare_cli -@pytest.fixture -def test_repo(): - test_dir = tempfile.mkdtemp() - try: - subprocess.run(["git", "init"], cwd=test_dir, check=True, capture_output=True) - subprocess.run( - ["git", "config", "user.email", "test@example.com"], - cwd=test_dir, - check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test User"], cwd=test_dir, check=True - ) - with open(os.path.join(test_dir, "file1.txt"), "w") as f: - f.write("v1") - subprocess.run(["git", "add", "file1.txt"], cwd=test_dir, check=True) - subprocess.run( - ["git", "commit", "-m", "initial commit"], cwd=test_dir, check=True - ) - yield test_dir - finally: - shutil.rmtree(test_dir) - - def test_bare_cli_help(): with patch("sys.argv", ["git-graphable", "--help"]): with pytest.raises(SystemExit) as e: @@ -48,6 +23,17 @@ def test_bare_cli_init(): assert os.path.exists(config_path) +def test_bare_cli_init_exists(): + with tempfile.TemporaryDirectory() as tmp_dir: + config_path = os.path.join(tmp_dir, ".git-graphable.toml") + with open(config_path, "w") as f: + f.write("exists") + with patch("sys.argv", ["git-graphable", "init", "-o", config_path]): + with pytest.raises(SystemExit) as e: + run_bare_cli() + assert e.value.code == 1 + + def test_bare_cli_analyze_basic(test_repo): with tempfile.NamedTemporaryFile(suffix=".mmd", delete=False) as tf: out_path = tf.name @@ -78,6 +64,16 @@ def test_bare_cli_engine_options(test_repo): os.unlink(out_path) +def test_bare_cli_invalid_engine(test_repo): + with patch( + "sys.argv", + ["git-graphable", "analyze", test_repo, "--engine", "invalid"], + ): + with pytest.raises(SystemExit) as e: + run_bare_cli() + assert e.value.code == 1 + + def test_bare_cli_conflicting_highlights(test_repo): with patch( "sys.argv", @@ -86,3 +82,36 @@ def test_bare_cli_conflicting_highlights(test_repo): with pytest.raises(SystemExit) as e: run_bare_cli() assert e.value.code == 1 + + +def test_bare_cli_check_mode_success(test_repo): + with patch( + "sys.argv", + ["git-graphable", "analyze", test_repo, "--check", "--min-score", "50"], + ): + # Should not exit(1) because initial commit has 100 score + run_bare_cli() + + +def test_bare_cli_check_mode_failure(test_repo): + # Create a WIP commit to lower score + with open(os.path.join(test_repo, "wip.txt"), "w") as f: + f.write("wip") + subprocess.run(["git", "add", "wip.txt"], cwd=test_repo, check=True) + subprocess.run(["git", "commit", "-m", "WIP: save"], cwd=test_repo, check=True) + + with patch( + "sys.argv", + [ + "git-graphable", + "analyze", + test_repo, + "--check", + "--min-score", + "99", + "--highlight-wip", + ], + ): + with pytest.raises(SystemExit) as e: + run_bare_cli() + assert e.value.code == 1 diff --git a/tests/test_commands.py b/tests/test_commands.py index b0cbdc7..8425bbb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,8 +1,60 @@ -from git_graphable.commands import get_extension -from git_graphable.core import Engine +import os +import subprocess +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +from git_graphable.commands import ensure_local_repo, get_extension, handle_output +from git_graphable.core import Engine, GitLogConfig, process_repo def test_get_extension(): assert get_extension(Engine.MERMAID, False) == ".mmd" assert get_extension(Engine.D2, False) == ".d2" assert get_extension(Engine.MERMAID, True) == ".svg" + + +def test_handle_output_stdout(test_repo): + """Test output to stdout ('-').""" + config = GitLogConfig() + graph = process_repo(test_repo, config) + content = handle_output(graph, Engine.MERMAID, "-", config) + assert content is not None + assert "flowchart TD" in content + + +def test_handle_output_image_inference(test_repo): + """Test inference of image format from file extension.""" + config = GitLogConfig() + graph = process_repo(test_repo, config) + + with tempfile.TemporaryDirectory() as tmp_dir: + out_path = os.path.join(tmp_dir, "graph.png") + with patch("git_graphable.commands.export_graph") as mock_export: + handle_output(graph, Engine.MERMAID, out_path, config) + mock_export.assert_called_once() + # Verify as_image was inferred as True + kwargs = mock_export.call_args[1] + assert kwargs.get("as_image") is True + + +def test_ensure_local_repo_remote(): + """Test cloning of remote repository.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0) + path, temp_repo = ensure_local_repo("https://github.com/user/repo") + assert temp_repo is not None + mock_run.assert_called_once() + assert "clone" in mock_run.call_args[0][0] + temp_repo.cleanup() + + +def test_ensure_local_repo_failure(): + """Test handling of failed clone.""" + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError( + 1, "clone", stderr=b"auth failed" + ) + with pytest.raises(RuntimeError, match="Failed to clone repository"): + ensure_local_repo("https://github.com/user/repo")