dboss-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+
20
+ - name: Install dependencies
21
+ run: pip install -e ".[dev]"
22
+
23
+ - name: Run tests
24
+ run: pytest
@@ -0,0 +1,43 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+
19
+ - name: Install build tool
20
+ run: pip install build
21
+
22
+ - name: Build sdist and wheel
23
+ run: python -m build
24
+
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write
36
+
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,45 @@
1
+ # Virtual environment
2
+ .venv/
3
+ venv/
4
+ env/
5
+
6
+ # Python cache
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+
11
+ # Build & distribution
12
+ *.egg-info/
13
+ dist/
14
+ build/
15
+ *.egg
16
+
17
+ # Package installer
18
+ pip-wheel-metadata/
19
+ .pip/
20
+
21
+ # Secrets & credentials
22
+ .env
23
+ .env.*
24
+ *.pem
25
+ secrets.json
26
+ secrets.toml
27
+
28
+ # IDE
29
+ .idea/
30
+ .vscode/
31
+ *.swp
32
+ *.swo
33
+
34
+ # OS
35
+ .DS_Store
36
+ Thumbs.db
37
+ desktop.ini
38
+
39
+ # pytest / coverage
40
+ .pytest_cache/
41
+ .coverage
42
+ htmlcov/
43
+
44
+ # mypy
45
+ .mypy_cache/
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: dboss-cli
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: click
6
+ Requires-Dist: requests
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest; extra == 'dev'
9
+ Requires-Dist: pytest-mock; extra == 'dev'
Binary file
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dboss-cli"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.12"
9
+ dependencies = ["click", "requests"]
10
+
11
+ [project.scripts]
12
+ dboss = "dboss.cli:main"
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest", "pytest-mock"]
16
+
17
+ [tool.pytest.ini_options]
18
+ testpaths = ["tests"]
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/dboss"]
File without changes
@@ -0,0 +1,63 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from dboss.git_utils import GitError, commit as commit_fn, get_staged_diff
6
+ from dboss.ollama_client import OllamaError, generate, strip_code_fences
7
+ from dboss.prompts import build_commit_prompt
8
+
9
+
10
+ @click.group()
11
+ def main():
12
+ """dboss — git commit message generator."""
13
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+
16
+
17
+ @main.command()
18
+ def hello():
19
+ """Smoke test: verify the CLI is installed correctly."""
20
+ click.echo("hello from dboss")
21
+
22
+
23
+ @main.command()
24
+ def commit():
25
+ """Generate a commit message for staged changes and commit."""
26
+ try:
27
+ diff = get_staged_diff()
28
+ except GitError as e:
29
+ raise click.ClickException(str(e))
30
+
31
+ if not diff:
32
+ click.echo("No staged changes.")
33
+ return
34
+
35
+ prompt = build_commit_prompt(diff)
36
+
37
+ while True:
38
+ try:
39
+ message = generate(prompt)
40
+ except OllamaError as e:
41
+ raise click.ClickException(str(e))
42
+
43
+ message = strip_code_fences(message)
44
+ click.echo(f"\nSuggested commit message:\n\n {message}\n")
45
+ choice = click.prompt(
46
+ "[y] accept [r] regenerate [n] cancel",
47
+ type=click.Choice(["y", "r", "n"]),
48
+ default="y",
49
+ show_choices=False,
50
+ )
51
+
52
+ if choice == "y":
53
+ try:
54
+ commit_fn(message)
55
+ except GitError as e:
56
+ raise click.ClickException(str(e))
57
+ click.echo("Commit created.")
58
+ break
59
+ elif choice == "r":
60
+ continue
61
+ else:
62
+ click.echo("Cancelled.")
63
+ break
@@ -0,0 +1,41 @@
1
+ import subprocess
2
+
3
+
4
+ class GitError(Exception):
5
+ pass
6
+
7
+
8
+ def get_staged_diff() -> str:
9
+ try:
10
+ subprocess.run(
11
+ ["git", "rev-parse", "--git-dir"],
12
+ capture_output=True,
13
+ check=True,
14
+ )
15
+ except FileNotFoundError:
16
+ raise GitError("git komutu bulunamadı. Git kurulu mu?")
17
+ except subprocess.CalledProcessError:
18
+ raise GitError("Bu dizin bir git reposu değil.")
19
+
20
+ try:
21
+ result = subprocess.run(
22
+ ["git", "diff", "--staged"],
23
+ capture_output=True,
24
+ encoding="utf-8",
25
+ check=True,
26
+ )
27
+ return result.stdout
28
+ except subprocess.CalledProcessError as e:
29
+ raise GitError(f"git diff başarısız: {e.stderr.strip()}")
30
+
31
+
32
+ def commit(message: str) -> None:
33
+ try:
34
+ subprocess.run(
35
+ ["git", "commit", "-m", message],
36
+ capture_output=True,
37
+ encoding="utf-8",
38
+ check=True,
39
+ )
40
+ except subprocess.CalledProcessError as e:
41
+ raise GitError(f"git commit başarısız: {e.stderr.strip()}")
@@ -0,0 +1,37 @@
1
+ import os
2
+ import re
3
+
4
+ import requests
5
+
6
+ OLLAMA_BASE_URL = "http://localhost:11434"
7
+ DEFAULT_MODEL = os.environ.get("DBOSS_MODEL", "qwen2.5-coder:3b")
8
+
9
+
10
+ def strip_code_fences(text: str) -> str:
11
+ # Remove opening fence line: ```plaintext, ```bash, ``` etc.
12
+ text = re.sub(r"^```[^\n]*\n", "", text)
13
+ # Remove closing fence line
14
+ text = re.sub(r"\n```\s*$", "", text)
15
+ return text.strip()
16
+
17
+
18
+ class OllamaError(Exception):
19
+ pass
20
+
21
+
22
+ def generate(prompt: str, model: str = DEFAULT_MODEL) -> str:
23
+ url = f"{OLLAMA_BASE_URL}/api/generate"
24
+ payload = {"model": model, "prompt": prompt, "stream": False}
25
+ try:
26
+ resp = requests.post(url, json=payload, timeout=120)
27
+ except requests.exceptions.ConnectionError:
28
+ raise OllamaError(
29
+ "Ollama'ya bağlanılamadı. `ollama serve` çalışıyor mu? (localhost:11434)"
30
+ )
31
+ if resp.status_code == 404:
32
+ raise OllamaError(
33
+ f"Model bulunamadı: {model!r}. `ollama pull {model}` çalıştır."
34
+ )
35
+ if not resp.ok:
36
+ raise OllamaError(f"Ollama HTTP {resp.status_code}: {resp.text[:200]}")
37
+ return resp.json()["response"]
@@ -0,0 +1,17 @@
1
+ def build_commit_prompt(diff: str) -> str:
2
+ return (
3
+ "You are a commit message generator.\n"
4
+ "Rules:\n"
5
+ "- Use Conventional Commits format: feat:, fix:, docs:, refactor:, test:, chore:, etc.\n"
6
+ "- Write in English.\n"
7
+ "- Return ONLY the commit message. No explanation, no markdown, no quotes.\n"
8
+ "- Do NOT wrap the message in code fences or backticks.\n"
9
+ "- Do NOT add a language tag like 'plaintext'. Output raw text only.\n"
10
+ "- The first (summary) line must not exceed 72 characters.\n"
11
+ "- If needed, add a blank line after the summary, then a body.\n"
12
+ "- Output EXACTLY ONE commit message. Do NOT produce a list of multiple commit messages.\n"
13
+ "- Do NOT invent changes that are not in the diff. Only describe what the diff actually shows.\n"
14
+ "- Keep it to a single summary line, optionally followed by a blank line and a short body.\n\n"
15
+ "Git diff to summarize:\n\n"
16
+ f"{diff}"
17
+ )
File without changes
@@ -0,0 +1,53 @@
1
+ import subprocess
2
+ from subprocess import CalledProcessError
3
+ from unittest.mock import MagicMock
4
+
5
+ import pytest
6
+
7
+ from dboss.git_utils import GitError, commit, get_staged_diff
8
+
9
+
10
+ # --- get_staged_diff ---
11
+
12
+ def test_get_staged_diff_returns_diff(mocker):
13
+ mocker.patch("subprocess.run", side_effect=[
14
+ MagicMock(),
15
+ MagicMock(stdout="diff --git a/foo.py b/foo.py\n+print('hello')\n"),
16
+ ])
17
+ result = get_staged_diff()
18
+ assert "diff" in result
19
+
20
+
21
+ def test_get_staged_diff_empty(mocker):
22
+ mocker.patch("subprocess.run", side_effect=[
23
+ MagicMock(),
24
+ MagicMock(stdout=""),
25
+ ])
26
+ assert get_staged_diff() == ""
27
+
28
+
29
+ def test_get_staged_diff_git_not_found(mocker):
30
+ mocker.patch("subprocess.run", side_effect=FileNotFoundError)
31
+ with pytest.raises(GitError, match="git komutu bulunamadı"):
32
+ get_staged_diff()
33
+
34
+
35
+ def test_get_staged_diff_not_a_repo(mocker):
36
+ mocker.patch("subprocess.run", side_effect=CalledProcessError(128, "git"))
37
+ with pytest.raises(GitError, match="git reposu değil"):
38
+ get_staged_diff()
39
+
40
+
41
+ # --- commit ---
42
+
43
+ def test_commit_success(mocker):
44
+ mocker.patch("subprocess.run", return_value=MagicMock())
45
+ commit("feat: add login") # exception çıkmamalı
46
+
47
+
48
+ def test_commit_failure(mocker):
49
+ err = CalledProcessError(1, "git")
50
+ err.stderr = "nothing to commit"
51
+ mocker.patch("subprocess.run", side_effect=err)
52
+ with pytest.raises(GitError, match="git commit başarısız"):
53
+ commit("feat: add login")
@@ -0,0 +1,59 @@
1
+ import pytest
2
+ import requests
3
+
4
+ from dboss.ollama_client import OllamaError, generate, strip_code_fences
5
+
6
+
7
+ # --- strip_code_fences ---
8
+
9
+ @pytest.mark.parametrize("text,expected", [
10
+ ("```plaintext\nfeat: add login\n```", "feat: add login"),
11
+ ("```bash\nchore: update deps\n```", "chore: update deps"),
12
+ ("```\nfix: null pointer\n```", "fix: null pointer"),
13
+ ("feat: plain text", "feat: plain text"),
14
+ (" feat: trim spaces ", "feat: trim spaces"),
15
+ ])
16
+ def test_strip_code_fences(text, expected):
17
+ assert strip_code_fences(text) == expected
18
+
19
+
20
+ # --- generate ---
21
+
22
+ def test_generate_success(mocker):
23
+ mock_resp = mocker.MagicMock()
24
+ mock_resp.ok = True
25
+ mock_resp.status_code = 200
26
+ mock_resp.json.return_value = {"response": "feat: add login"}
27
+ mocker.patch("dboss.ollama_client.requests.post", return_value=mock_resp)
28
+
29
+ assert generate("some prompt") == "feat: add login"
30
+
31
+
32
+ def test_generate_connection_error(mocker):
33
+ mocker.patch(
34
+ "dboss.ollama_client.requests.post",
35
+ side_effect=requests.exceptions.ConnectionError,
36
+ )
37
+ with pytest.raises(OllamaError, match="bağlanılamadı"):
38
+ generate("some prompt")
39
+
40
+
41
+ def test_generate_404_raises_ollama_error(mocker):
42
+ mock_resp = mocker.MagicMock()
43
+ mock_resp.ok = False
44
+ mock_resp.status_code = 404
45
+ mocker.patch("dboss.ollama_client.requests.post", return_value=mock_resp)
46
+
47
+ with pytest.raises(OllamaError, match="Model bulunamadı"):
48
+ generate("some prompt")
49
+
50
+
51
+ def test_generate_http_error(mocker):
52
+ mock_resp = mocker.MagicMock()
53
+ mock_resp.ok = False
54
+ mock_resp.status_code = 500
55
+ mock_resp.text = "internal server error"
56
+ mocker.patch("dboss.ollama_client.requests.post", return_value=mock_resp)
57
+
58
+ with pytest.raises(OllamaError, match="HTTP 500"):
59
+ generate("some prompt")
@@ -0,0 +1,19 @@
1
+ from dboss.prompts import build_commit_prompt
2
+
3
+ DIFF = "diff --git a/foo.py b/foo.py\n+print('hello')"
4
+
5
+
6
+ def test_contains_diff():
7
+ assert DIFF in build_commit_prompt(DIFF)
8
+
9
+
10
+ def test_conventional_commits_instruction():
11
+ assert "Conventional Commits" in build_commit_prompt(DIFF)
12
+
13
+
14
+ def test_no_code_fences_instruction():
15
+ assert "code fences" in build_commit_prompt(DIFF)
16
+
17
+
18
+ def test_raw_text_only_instruction():
19
+ assert "raw text only" in build_commit_prompt(DIFF)