sase-github 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,46 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ lint:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v4
18
+ with:
19
+ enable-cache: true
20
+ cache-dependency-glob: "**/pyproject.toml"
21
+ - uses: extractions/setup-just@v2
22
+ - name: Set up Python
23
+ run: uv python install 3.12
24
+ - name: Install dependencies
25
+ run: just install
26
+ - name: Lint
27
+ run: just lint
28
+
29
+ test:
30
+ runs-on: ubuntu-latest
31
+ strategy:
32
+ matrix:
33
+ python-version: ["3.12", "3.13"]
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: astral-sh/setup-uv@v4
37
+ with:
38
+ enable-cache: true
39
+ cache-dependency-glob: "**/pyproject.toml"
40
+ - uses: extractions/setup-just@v2
41
+ - name: Set up Python ${{ matrix.python-version }}
42
+ run: uv python install ${{ matrix.python-version }}
43
+ - name: Install dependencies
44
+ run: just install
45
+ - name: Run tests
46
+ run: just test
@@ -0,0 +1,32 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+ permissions:
6
+ contents: read
7
+ id-token: write # for trusted publishing
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v4
14
+ - run: uv python install 3.12
15
+ - name: Build package
16
+ run: uv build
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: dist
20
+ path: dist/
21
+ publish:
22
+ needs: build
23
+ runs-on: ubuntu-latest
24
+ environment: pypi
25
+ permissions:
26
+ id-token: write
27
+ steps:
28
+ - uses: actions/download-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,24 @@
1
+ # sase-github - Agent Instructions
2
+
3
+ ## Overview
4
+ GitHub VCS plugin for sase. Provides GitHubPlugin (PR creation, gh CLI integration)
5
+ and GitHub-related xprompts (#gh, #pr, #new_pr_desc).
6
+
7
+ ## Build & Run
8
+ ```bash
9
+ just install # Install in editable mode with dev deps
10
+ just lint # ruff + mypy
11
+ just fmt # Auto-format
12
+ just test # pytest
13
+ just check # lint + test
14
+ ```
15
+
16
+ ## Architecture
17
+ - `src/sase_github/plugin.py` — GitHubPlugin class (extends `sase.vcs_provider.plugins._git_common.GitCommon`)
18
+ - `src/sase_github/xprompts/` — GitHub workflow YAML files discovered via `sase_xprompts` entry point
19
+ - Depends on `sase>=0.1.0` for base classes, hookspec, and script modules
20
+
21
+ ## Code Conventions
22
+ - Absolute imports: `from sase_github.plugin import GitHubPlugin`
23
+ - Target Python 3.12+
24
+ - Follow ruff rules matching sase core
@@ -0,0 +1,32 @@
1
+ # sase-github task runner
2
+
3
+ venv_dir := ".venv"
4
+ venv_bin := venv_dir / "bin"
5
+
6
+ default:
7
+ @just --list
8
+
9
+ _setup:
10
+ @[ -x {{ venv_bin }}/python ] || (uv venv {{ venv_dir }} && uv pip install -e ".[dev]")
11
+
12
+ install: _setup
13
+ uv pip install -e ".[dev]"
14
+
15
+ lint: _setup
16
+ {{ venv_bin }}/ruff check src/ tests/
17
+ {{ venv_bin }}/mypy
18
+
19
+ fmt: _setup
20
+ {{ venv_bin }}/ruff format src/ tests/
21
+ {{ venv_bin }}/ruff check --fix src/ tests/
22
+
23
+ test *args: _setup
24
+ {{ venv_bin }}/pytest {{ args }}
25
+
26
+ check: lint test
27
+
28
+ clean:
29
+ rm -rf build/ dist/ *.egg-info src/*.egg-info .mypy_cache/ .ruff_cache/ .pytest_cache/
30
+
31
+ build: _setup
32
+ {{ venv_bin }}/python -m build
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bryan Bugyi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: sase-github
3
+ Version: 0.1.0
4
+ Summary: GitHub VCS plugin for sase
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: sase>=0.1.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: mypy; extra == 'dev'
11
+ Requires-Dist: pytest; extra == 'dev'
12
+ Requires-Dist: pytest-cov; extra == 'dev'
13
+ Requires-Dist: pytest-mock; extra == 'dev'
14
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,82 @@
1
+ # sase-github — GitHub VCS Plugin for sase
2
+
3
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
4
+ [![mypy](https://img.shields.io/badge/type_checker-mypy-blue.svg)](https://mypy-lang.org/)
5
+ [![pytest](https://img.shields.io/badge/tests-pytest-blue.svg)](https://docs.pytest.org/)
6
+
7
+ ## Overview
8
+
9
+ **sase-github** is a plugin for [sase](https://github.com/bbugyi200/sase) that adds GitHub-specific VCS support. It
10
+ provides the `GitHubPlugin` VCS provider for GitHub-hosted repositories, integrating with the `gh` CLI for pull request
11
+ creation and management, along with GitHub-specific xprompt workflows.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install sase-github
17
+ ```
18
+
19
+ Or with [uv](https://docs.astral.sh/uv/):
20
+
21
+ ```bash
22
+ uv pip install sase-github
23
+ ```
24
+
25
+ Requires `sase>=0.1.0` as a dependency (installed automatically).
26
+
27
+ ## What's Included
28
+
29
+ ### VCS Provider
30
+
31
+ - **GitHubPlugin** — GitHub VCS provider that extends `GitCommon` with `gh` CLI integration for PR workflows
32
+
33
+ ### XPrompts
34
+
35
+ | XPrompt | Description |
36
+ | --------------- | ------------------------------------------ |
37
+ | `#gh` | GitHub-specific operations and workflows |
38
+ | `#pr` | Pull request creation and management |
39
+ | `#new_pr_desc` | Generate PR descriptions from commit diffs |
40
+
41
+ ## How It Works
42
+
43
+ sase-github uses Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/) to register
44
+ itself with sase core:
45
+
46
+ - **`sase_vcs`** — Registers `GitHubPlugin` as the `github` VCS provider
47
+ - **`sase_xprompts`** — Makes GitHub xprompts discoverable via plugin discovery
48
+
49
+ When sase detects a GitHub-hosted repository (via `gh` CLI), it automatically loads `GitHubPlugin` to handle VCS
50
+ operations like PR creation, branch management, and commit workflows.
51
+
52
+ ## Requirements
53
+
54
+ - Python 3.12+
55
+ - [sase](https://github.com/bbugyi200/sase) >= 0.1.0
56
+ - [gh](https://cli.github.com/) CLI (for GitHub API operations)
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ just install # Install in editable mode with dev deps
62
+ just fmt # Auto-format code
63
+ just lint # Run ruff + mypy
64
+ just test # Run tests
65
+ just check # All checks (lint + test)
66
+ ```
67
+
68
+ ## Project Structure
69
+
70
+ ```
71
+ src/sase_github/
72
+ ├── __init__.py # Package exports
73
+ ├── plugin.py # GitHubPlugin implementation
74
+ └── xprompts/
75
+ ├── gh.yml # GitHub operations workflow
76
+ ├── pr.yml # PR creation workflow
77
+ └── new_pr_desc.yml # PR description generation
78
+ ```
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sase-github"
7
+ version = "0.1.0"
8
+ description = "GitHub VCS plugin for sase"
9
+ requires-python = ">=3.12"
10
+ license = "MIT"
11
+ dependencies = ["sase>=0.1.0"]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "ruff",
16
+ "mypy",
17
+ "pytest",
18
+ "pytest-cov",
19
+ "pytest-mock",
20
+ ]
21
+
22
+ [project.entry-points."sase_vcs"]
23
+ github = "sase_github.plugin:GitHubPlugin"
24
+
25
+ [project.entry-points."sase_xprompts"]
26
+ sase_github = "sase_github"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/sase_github"]
30
+
31
+ [tool.ruff]
32
+ target-version = "py312"
33
+ line-length = 88
34
+ src = ["src", "tests"]
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "W", "F", "B", "C4", "UP"]
38
+ ignore = ["E501", "W291", "F401", "F821", "B007"]
39
+
40
+ [tool.mypy]
41
+ files = ["src"]
42
+ mypy_path = ["src"]
43
+ python_version = "3.12"
44
+ check_untyped_defs = true
45
+ disallow_incomplete_defs = true
46
+ disallow_untyped_defs = true
47
+ ignore_missing_imports = false
48
+
49
+ [[tool.mypy.overrides]]
50
+ module = ["sase.*"]
51
+ ignore_missing_imports = true
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ pythonpath = ["src", "tests"]
56
+ addopts = [
57
+ "--import-mode=importlib",
58
+ "--strict-markers",
59
+ "--strict-config",
60
+ "-v",
61
+ ]
@@ -0,0 +1,5 @@
1
+ """sase-github: GitHub VCS plugin for sase."""
2
+
3
+ from sase_github.plugin import GitHubPlugin
4
+
5
+ __all__ = ["GitHubPlugin"]
@@ -0,0 +1,44 @@
1
+ """GitHub VCS plugin implementation.
2
+
3
+ Handles git repositories hosted on GitHub (or similar hosted services).
4
+ Inherits shared git operations from :class:`GitCommon` and adds
5
+ GitHub-specific methods (``mail`` with PR creation, ``get_cl_number``
6
+ and ``get_change_url`` via ``gh`` CLI).
7
+ """
8
+
9
+ from sase.vcs_provider._hookspec import hookimpl
10
+ from sase.vcs_provider.plugins._git_common import GitCommon
11
+
12
+
13
+ class GitHubPlugin(GitCommon):
14
+ """Pluggy plugin for GitHub-hosted git repositories."""
15
+
16
+ @hookimpl
17
+ def vcs_get_change_url(self, cwd: str) -> tuple[bool, str | None]:
18
+ out = self._run(["gh", "pr", "view", "--json", "url", "-q", ".url"], cwd)
19
+ if out.success:
20
+ url = out.stdout.strip()
21
+ return (True, url) if url else (True, None)
22
+ return (True, None)
23
+
24
+ @hookimpl
25
+ def vcs_get_cl_number(self, cwd: str) -> tuple[bool, str | None]:
26
+ out = self._run(["gh", "pr", "view", "--json", "number", "-q", ".number"], cwd)
27
+ if out.success:
28
+ number = out.stdout.strip()
29
+ return (True, number) if number else (True, None)
30
+ return (True, None)
31
+
32
+ @hookimpl
33
+ def vcs_mail(self, revision: str, cwd: str) -> tuple[bool, str | None]:
34
+ out = self._run(["git", "push", "-u", "origin", revision], cwd)
35
+ if not out.success:
36
+ return self._to_result(out, "git push")
37
+ pr_check = self._run(
38
+ ["gh", "pr", "view", "--json", "number", "-q", ".number"], cwd
39
+ )
40
+ if not pr_check.success:
41
+ pr_create = self._run(["gh", "pr", "create", "--fill"], cwd)
42
+ if not pr_create.success:
43
+ return self._to_result(pr_create, "gh pr create")
44
+ return (True, None)
@@ -0,0 +1,87 @@
1
+ wraps_all: true
2
+
3
+ input:
4
+ - name: gh_ref
5
+ type: word
6
+ - name: n
7
+ type: int
8
+ default: null
9
+ - name: release
10
+ type: bool
11
+ default: true
12
+
13
+ steps:
14
+ - name: setup
15
+ python: |
16
+ from sase.scripts.gh_setup import main
17
+ main(
18
+ gh_ref={{ gh_ref | tojson }},
19
+ n={{ n | tojson }},
20
+ release={{ release | tojson }},
21
+ )
22
+ output:
23
+ project_name: word
24
+ project_file: path
25
+ workspace_dir: path
26
+ workspace_num: int
27
+ checkout_target: word
28
+ primary_workspace_dir: path
29
+ should_release: bool
30
+
31
+ - name: prepare
32
+ bash: |
33
+ # Save diff backup if workspace is dirty
34
+ if ! git diff --quiet HEAD 2>/dev/null; then
35
+ git diff HEAD > "/tmp/gh-workspace-backup-$(date +%s).patch"
36
+ fi
37
+ git checkout . && git clean -fd
38
+ git fetch --quiet
39
+ git pull --rebase --quiet 2>&1 || true
40
+ echo "head_before=$(git rev-parse HEAD)"
41
+ echo "success=true"
42
+ output: { success: bool, head_before: word }
43
+
44
+ - name: inject
45
+ prompt_part: ""
46
+
47
+ - name: release
48
+ if: "{{ setup.should_release }}"
49
+ python: |
50
+ from sase.running_field import release_workspace
51
+
52
+ release_workspace(
53
+ {{ setup.project_file | tojson }},
54
+ {{ setup.workspace_num }},
55
+ f"gh-{{ gh_ref }}",
56
+ {{ gh_ref | tojson }} if "/" not in {{ gh_ref | tojson }} else None,
57
+ )
58
+ print("released=true")
59
+ output: { released: bool }
60
+
61
+ - name: diff
62
+ bash: |
63
+ head_before="{{ prepare.head_before }}"
64
+ if [ -z "$head_before" ]; then
65
+ exit 0
66
+ fi
67
+ head_now=$(git rev-parse HEAD)
68
+ diff_file=$(mktemp /tmp/sase-gh-XXXXXX.diff)
69
+ if [ "$head_now" != "$head_before" ]; then
70
+ # Commits were made — show only the last commit's diff
71
+ git diff HEAD~1 HEAD > "$diff_file" 2>/dev/null
72
+ commit_msg=$(git log -1 --format='%s' HEAD)
73
+ echo "meta_commit_message=$commit_msg"
74
+ else
75
+ # No commits — show uncommitted changes
76
+ git diff HEAD > "$diff_file" 2>/dev/null
77
+ git ls-files --others --exclude-standard -z 2>/dev/null \
78
+ | while IFS= read -r -d '' f; do
79
+ git diff --no-index /dev/null "$f" 2>/dev/null
80
+ done >> "$diff_file"
81
+ fi
82
+ if [ -s "$diff_file" ]; then
83
+ echo "diff_path=$diff_file"
84
+ else
85
+ rm -f "$diff_file"
86
+ fi
87
+ output: { diff_path: path, meta_commit_message: line }
@@ -0,0 +1,64 @@
1
+ input:
2
+ - name: name
3
+ type: word
4
+
5
+ steps:
6
+ - name: get_context
7
+ python: |
8
+ from sase.scripts.new_pr_desc_get_context import main
9
+ main(name={{ name | tojson }})
10
+ output:
11
+ error: line
12
+ description: line
13
+ diff_file: path
14
+ commits: text
15
+ workspace_dir: path
16
+ default_branch: word
17
+ branch_name: word
18
+
19
+ - name: generate
20
+ if: "{{ not get_context.error }}"
21
+ agent: |
22
+ Generate a PR title and body for the following changes.
23
+
24
+ ## ChangeSpec Description
25
+ {{ get_context.description }}
26
+
27
+ ## Commits
28
+ {{ get_context.commits }}
29
+
30
+ ## Diff
31
+ @{{ get_context.diff_file }}
32
+
33
+ ## Instructions
34
+ - PR title: Use conventional commit format (e.g., "feat: ...", "fix: ..."), under 72 characters
35
+ - PR body: Start with "## Summary" followed by 1-3 bullet points
36
+
37
+ Output EXACTLY in this format (no extra text):
38
+ TITLE: <your title here>
39
+ BODY:
40
+ <your body here>
41
+
42
+ - name: apply
43
+ if: "{{ not get_context.error }}"
44
+ bash: |
45
+ # Check if PR exists for this branch
46
+ PR_NUMBER=$(gh pr view "{{ get_context.branch_name }}" --json number --jq '.number' 2>/dev/null || echo "")
47
+ if [ -z "$PR_NUMBER" ]; then
48
+ echo "applied=false"
49
+ echo "No existing PR found for branch {{ get_context.branch_name }}"
50
+ exit 0
51
+ fi
52
+
53
+ # Extract title and body from response
54
+ RESPONSE="{{ _response }}"
55
+ TITLE=$(echo "$RESPONSE" | grep -m1 '^TITLE: ' | sed 's/^TITLE: //')
56
+ BODY=$(echo "$RESPONSE" | sed -n '/^BODY:$/,$ p' | tail -n +2)
57
+
58
+ if [ -n "$TITLE" ] && [ -n "$BODY" ]; then
59
+ gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY" 2>/dev/null && echo "applied=true" || echo "applied=false"
60
+ else
61
+ echo "applied=false"
62
+ echo "Could not parse title/body from response"
63
+ fi
64
+ output: { applied: bool }
@@ -0,0 +1,88 @@
1
+ input:
2
+ - name: name
3
+ type: word
4
+
5
+ steps:
6
+ - name: create_branch
7
+ bash: |
8
+ BRANCH="{{ name }}"
9
+ git checkout -b "$BRANCH" 2>&1
10
+ git push -u origin "$BRANCH" 2>&1
11
+ echo "branch_name=$BRANCH"
12
+ echo "meta_changespec=$BRANCH"
13
+ output:
14
+ branch_name: word
15
+
16
+ - name: inject
17
+ prompt_part: ""
18
+
19
+ - name: create_changespec
20
+ hidden: true
21
+ python: |
22
+ from sase.scripts.pr_create_changespec import main
23
+ main(
24
+ name={{ name | tojson }},
25
+ prompt={{ _prompt | tojson }},
26
+ response={{ _response | tojson }},
27
+ )
28
+ output:
29
+ success: bool
30
+ cl_name: word
31
+ project_file: path
32
+ default_branch: word
33
+
34
+ - name: create_pr
35
+ hidden: true
36
+ if: "{{ create_changespec.success | default(false) }}"
37
+ bash: |
38
+ BRANCH="{{ create_branch.branch_name }}"
39
+ DEFAULT_BRANCH="{{ create_changespec.default_branch }}"
40
+
41
+ # Check for existing PR first
42
+ EXISTING_URL=$(gh pr view "$BRANCH" --json url --jq '.url' 2>/dev/null || echo "")
43
+ if [ -n "$EXISTING_URL" ]; then
44
+ echo "pr_url=$EXISTING_URL"
45
+ echo "success=true"
46
+ exit 0
47
+ fi
48
+
49
+ # Get PR title from first commit
50
+ PR_TITLE=$(git log --format='%s' "origin/$DEFAULT_BRANCH".."$BRANCH" 2>/dev/null | tail -1)
51
+ if [ -z "$PR_TITLE" ]; then
52
+ PR_TITLE="Agent changes on branch $BRANCH"
53
+ fi
54
+
55
+ PR_URL=$(gh pr create \
56
+ --base "$DEFAULT_BRANCH" \
57
+ --head "$BRANCH" \
58
+ --title "$PR_TITLE" \
59
+ --body "Automated PR from sase." 2>&1) || true
60
+
61
+ if echo "$PR_URL" | grep -q "^http"; then
62
+ echo "pr_url=$PR_URL"
63
+ echo "success=true"
64
+ else
65
+ echo "pr_url="
66
+ echo "success=false"
67
+ echo "Error: $PR_URL" >&2
68
+ fi
69
+ output: { pr_url: line, success: bool }
70
+
71
+ - name: update_cl
72
+ hidden: true
73
+ if: "{{ create_pr.success | default(false) }}"
74
+ python: |
75
+ from sase.status_state_machine import update_changespec_cl_atomic
76
+
77
+ project_file = {{ create_changespec.project_file | tojson }}
78
+ cl_name = {{ create_changespec.cl_name | tojson }}
79
+ pr_url = {{ create_pr.pr_url | tojson }}
80
+
81
+ if pr_url and pr_url.startswith("http"):
82
+ update_changespec_cl_atomic(project_file, cl_name, pr_url)
83
+ print("updated=true")
84
+ print(f"pr_url={pr_url}")
85
+ else:
86
+ print("updated=false")
87
+ print(f"pr_url={pr_url}")
88
+ output: { updated: bool, pr_url: line }
File without changes
@@ -0,0 +1,308 @@
1
+ """Tests for the GitHub pluggy plugin.
2
+
3
+ Verifies that :class:`GitHubPlugin` works correctly when routed through
4
+ :class:`VCSPluginManager`.
5
+ """
6
+
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pluggy
10
+ import pytest
11
+ from sase.vcs_provider._base import VCSProvider
12
+ from sase.vcs_provider._command_runner import CommandRunner
13
+ from sase.vcs_provider._hookspec import VCSHookSpec
14
+ from sase.vcs_provider._plugin_manager import VCSPluginManager
15
+ from sase_github.plugin import GitHubPlugin
16
+
17
+ _MOCK_TARGET = "sase.vcs_provider._command_runner.subprocess.run"
18
+
19
+
20
+ @pytest.fixture
21
+ def github_provider() -> VCSPluginManager:
22
+ """Create a VCSPluginManager backed by GitHubPlugin."""
23
+ pm = pluggy.PluginManager("sase_vcs")
24
+ pm.add_hookspecs(VCSHookSpec)
25
+ pm.register(GitHubPlugin())
26
+ return VCSPluginManager(pm)
27
+
28
+
29
+ # === Tests for isinstance / type checks ===
30
+
31
+
32
+ def test_github_plugin_provider_is_vcs_provider(
33
+ github_provider: VCSPluginManager,
34
+ ) -> None:
35
+ """The plugin-backed provider is a VCSProvider."""
36
+ assert isinstance(github_provider, VCSProvider)
37
+
38
+
39
+ def test_github_plugin_is_command_runner() -> None:
40
+ """GitHubPlugin inherits from CommandRunner."""
41
+ plugin = GitHubPlugin()
42
+ assert isinstance(plugin, CommandRunner)
43
+
44
+
45
+ # === Tests for core git operations via plugin ===
46
+
47
+
48
+ @patch(_MOCK_TARGET)
49
+ def test_plugin_checkout_success(
50
+ mock_run: MagicMock, github_provider: VCSPluginManager
51
+ ) -> None:
52
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
53
+ success, error = github_provider.checkout("main", "/workspace")
54
+
55
+ assert success is True
56
+ assert error is None
57
+ assert mock_run.call_args[0][0] == ["git", "checkout", "main"]
58
+
59
+
60
+ @patch(_MOCK_TARGET)
61
+ def test_plugin_diff_success(
62
+ mock_run: MagicMock, github_provider: VCSPluginManager
63
+ ) -> None:
64
+ mock_run.return_value = MagicMock(returncode=0, stdout="diff output", stderr="")
65
+ success, text = github_provider.diff("/workspace")
66
+
67
+ assert success is True
68
+ assert text == "diff output"
69
+
70
+
71
+ @patch(_MOCK_TARGET)
72
+ def test_plugin_add_remove(
73
+ mock_run: MagicMock, github_provider: VCSPluginManager
74
+ ) -> None:
75
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
76
+ success, error = github_provider.add_remove("/workspace")
77
+
78
+ assert success is True
79
+ assert error is None
80
+ assert mock_run.call_args[0][0] == ["git", "add", "-A"]
81
+
82
+
83
+ @patch(_MOCK_TARGET)
84
+ def test_plugin_commit(mock_run: MagicMock, github_provider: VCSPluginManager) -> None:
85
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
86
+ success, error = github_provider.commit("feature", "/tmp/msg.txt", "/workspace")
87
+
88
+ assert success is True
89
+ assert error is None
90
+
91
+
92
+ # === Tests for GitHub-specific operations ===
93
+
94
+
95
+ @patch(_MOCK_TARGET)
96
+ def test_plugin_get_change_url_with_pr(
97
+ mock_run: MagicMock, github_provider: VCSPluginManager
98
+ ) -> None:
99
+ mock_run.return_value = MagicMock(
100
+ returncode=0, stdout="https://github.com/user/repo/pull/42\n", stderr=""
101
+ )
102
+ success, url = github_provider.get_change_url("/workspace")
103
+
104
+ assert success is True
105
+ assert url == "https://github.com/user/repo/pull/42"
106
+
107
+
108
+ @patch(_MOCK_TARGET)
109
+ def test_plugin_get_change_url_no_pr(
110
+ mock_run: MagicMock, github_provider: VCSPluginManager
111
+ ) -> None:
112
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="no PR")
113
+ success, url = github_provider.get_change_url("/workspace")
114
+
115
+ assert success is True
116
+ assert url is None
117
+
118
+
119
+ @patch(_MOCK_TARGET)
120
+ def test_plugin_get_cl_number_with_pr(
121
+ mock_run: MagicMock, github_provider: VCSPluginManager
122
+ ) -> None:
123
+ mock_run.return_value = MagicMock(returncode=0, stdout="42\n", stderr="")
124
+ success, number = github_provider.get_cl_number("/workspace")
125
+
126
+ assert success is True
127
+ assert number == "42"
128
+
129
+
130
+ @patch(_MOCK_TARGET)
131
+ def test_plugin_get_cl_number_no_pr(
132
+ mock_run: MagicMock, github_provider: VCSPluginManager
133
+ ) -> None:
134
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="no PR")
135
+ success, number = github_provider.get_cl_number("/workspace")
136
+
137
+ assert success is True
138
+ assert number is None
139
+
140
+
141
+ @patch(_MOCK_TARGET)
142
+ def test_plugin_mail_creates_pr(
143
+ mock_run: MagicMock, github_provider: VCSPluginManager
144
+ ) -> None:
145
+ """mail pushes and creates a PR when none exists."""
146
+ # First call: git push (success)
147
+ # Second call: gh pr view (fail = no existing PR)
148
+ # Third call: gh pr create (success)
149
+ mock_run.side_effect = [
150
+ MagicMock(returncode=0, stdout="", stderr=""),
151
+ MagicMock(returncode=1, stdout="", stderr="no PR"),
152
+ MagicMock(returncode=0, stdout="", stderr=""),
153
+ ]
154
+ success, error = github_provider.mail("feature-branch", "/workspace")
155
+
156
+ assert success is True
157
+ assert error is None
158
+ assert mock_run.call_count == 3
159
+ assert mock_run.call_args_list[0][0][0] == [
160
+ "git",
161
+ "push",
162
+ "-u",
163
+ "origin",
164
+ "feature-branch",
165
+ ]
166
+ assert mock_run.call_args_list[2][0][0] == ["gh", "pr", "create", "--fill"]
167
+
168
+
169
+ @patch(_MOCK_TARGET)
170
+ def test_plugin_mail_existing_pr(
171
+ mock_run: MagicMock, github_provider: VCSPluginManager
172
+ ) -> None:
173
+ """mail pushes but skips PR creation when PR already exists."""
174
+ mock_run.side_effect = [
175
+ MagicMock(returncode=0, stdout="", stderr=""), # git push
176
+ MagicMock(returncode=0, stdout="42\n", stderr=""), # gh pr view (PR exists)
177
+ ]
178
+ success, error = github_provider.mail("feature-branch", "/workspace")
179
+
180
+ assert success is True
181
+ assert error is None
182
+ assert mock_run.call_count == 2
183
+
184
+
185
+ # === Tests for prepare_description_for_reword ===
186
+
187
+
188
+ def test_plugin_prepare_description_passthrough(
189
+ github_provider: VCSPluginManager,
190
+ ) -> None:
191
+ """Git plugins pass description through unchanged."""
192
+ result = github_provider.prepare_description_for_reword("hello\nworld")
193
+ assert result == "hello\nworld"
194
+
195
+
196
+ # === Direct plugin method tests (GitHub-specific) ===
197
+
198
+
199
+ @patch(_MOCK_TARGET)
200
+ def test_direct_get_change_url_with_pr(mock_run: MagicMock) -> None:
201
+ """Test GitHubPlugin.vcs_get_change_url when PR exists."""
202
+ mock_run.return_value = MagicMock(
203
+ returncode=0,
204
+ stdout="https://github.com/user/repo/pull/42\n",
205
+ stderr="",
206
+ )
207
+
208
+ plugin = GitHubPlugin()
209
+ success, url = plugin.vcs_get_change_url("/workspace")
210
+
211
+ assert success is True
212
+ assert url == "https://github.com/user/repo/pull/42"
213
+
214
+
215
+ @patch(_MOCK_TARGET)
216
+ def test_direct_get_change_url_no_pr(mock_run: MagicMock) -> None:
217
+ """Test GitHubPlugin.vcs_get_change_url when no PR exists."""
218
+ mock_run.return_value = MagicMock(
219
+ returncode=1, stdout="", stderr="no pull requests found"
220
+ )
221
+
222
+ plugin = GitHubPlugin()
223
+ success, url = plugin.vcs_get_change_url("/workspace")
224
+
225
+ assert success is True
226
+ assert url is None
227
+
228
+
229
+ @patch(_MOCK_TARGET)
230
+ def test_direct_get_cl_number_with_pr(mock_run: MagicMock) -> None:
231
+ """Test GitHubPlugin.vcs_get_cl_number when PR exists."""
232
+ mock_run.return_value = MagicMock(returncode=0, stdout="42\n", stderr="")
233
+
234
+ plugin = GitHubPlugin()
235
+ success, number = plugin.vcs_get_cl_number("/workspace")
236
+
237
+ assert success is True
238
+ assert number == "42"
239
+
240
+
241
+ @patch(_MOCK_TARGET)
242
+ def test_direct_get_cl_number_no_pr(mock_run: MagicMock) -> None:
243
+ """Test GitHubPlugin.vcs_get_cl_number when no PR exists."""
244
+ mock_run.return_value = MagicMock(
245
+ returncode=1, stdout="", stderr="no pull requests found"
246
+ )
247
+
248
+ plugin = GitHubPlugin()
249
+ success, number = plugin.vcs_get_cl_number("/workspace")
250
+
251
+ assert success is True
252
+ assert number is None
253
+
254
+
255
+ @patch(_MOCK_TARGET)
256
+ def test_direct_mail_push_and_create_pr(mock_run: MagicMock) -> None:
257
+ """Test GitHubPlugin.vcs_mail pushes and creates PR when none exists."""
258
+ mock_run.side_effect = [
259
+ MagicMock(returncode=0, stdout="", stderr=""), # git push
260
+ MagicMock(returncode=1, stdout="", stderr="no PR"), # gh pr view (no PR)
261
+ MagicMock(returncode=0, stdout="", stderr=""), # gh pr create
262
+ ]
263
+
264
+ plugin = GitHubPlugin()
265
+ success, error = plugin.vcs_mail("feature-branch", "/workspace")
266
+
267
+ assert success is True
268
+ assert error is None
269
+ assert mock_run.call_count == 3
270
+ assert mock_run.call_args_list[0][0][0] == [
271
+ "git",
272
+ "push",
273
+ "-u",
274
+ "origin",
275
+ "feature-branch",
276
+ ]
277
+ assert mock_run.call_args_list[2][0][0] == ["gh", "pr", "create", "--fill"]
278
+
279
+
280
+ @patch(_MOCK_TARGET)
281
+ def test_direct_mail_push_existing_pr(mock_run: MagicMock) -> None:
282
+ """Test GitHubPlugin.vcs_mail just pushes when PR already exists."""
283
+ mock_run.side_effect = [
284
+ MagicMock(returncode=0, stdout="", stderr=""), # git push
285
+ MagicMock(returncode=0, stdout="42\n", stderr=""), # gh pr view (PR exists)
286
+ ]
287
+
288
+ plugin = GitHubPlugin()
289
+ success, error = plugin.vcs_mail("feature-branch", "/workspace")
290
+
291
+ assert success is True
292
+ assert error is None
293
+ assert mock_run.call_count == 2
294
+
295
+
296
+ @patch(_MOCK_TARGET)
297
+ def test_direct_mail_push_fails(mock_run: MagicMock) -> None:
298
+ """Test GitHubPlugin.vcs_mail when push fails."""
299
+ mock_run.return_value = MagicMock(
300
+ returncode=1, stdout="", stderr="permission denied"
301
+ )
302
+
303
+ plugin = GitHubPlugin()
304
+ success, error = plugin.vcs_mail("feature-branch", "/workspace")
305
+
306
+ assert success is False
307
+ assert error is not None
308
+ assert "git push failed" in error