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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/git_graphable/issues/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 "",
Expand Down
40 changes: 40 additions & 0 deletions src/git_graphable/issues/gitlab.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 14 additions & 5 deletions src/git_graphable/issues/script.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Script-based issue tracker integration.
"""

import json
import shlex
import subprocess
from typing import Dict, List
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/git_graphable/prs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from .base import PullRequestInfo, PullRequestProvider
from .github import GitHubPullRequestProvider
from .gitlab import GitLabPullRequestProvider
from .script import ScriptPullRequestProvider


Expand All @@ -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 ""
Expand Down
54 changes: 54 additions & 0 deletions src/git_graphable/prs/gitlab.py
Original file line number Diff line number Diff line change
@@ -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 []
25 changes: 25 additions & 0 deletions tests/issues/test_gitlab.py
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 19 additions & 0 deletions tests/issues/test_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions tests/prs/test_gitlab.py
Original file line number Diff line number Diff line change
@@ -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"
79 changes: 54 additions & 25 deletions tests/test_bare_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
import shutil
import subprocess
import tempfile
from unittest.mock import patch
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Loading