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.
- sase_github-0.1.0/.github/workflows/ci.yml +46 -0
- sase_github-0.1.0/.github/workflows/publish.yml +32 -0
- sase_github-0.1.0/CLAUDE.md +24 -0
- sase_github-0.1.0/Justfile +32 -0
- sase_github-0.1.0/LICENSE +21 -0
- sase_github-0.1.0/PKG-INFO +14 -0
- sase_github-0.1.0/README.md +82 -0
- sase_github-0.1.0/pyproject.toml +61 -0
- sase_github-0.1.0/src/sase_github/__init__.py +5 -0
- sase_github-0.1.0/src/sase_github/plugin.py +44 -0
- sase_github-0.1.0/src/sase_github/xprompts/gh.yml +87 -0
- sase_github-0.1.0/src/sase_github/xprompts/new_pr_desc.yml +64 -0
- sase_github-0.1.0/src/sase_github/xprompts/pr.yml +88 -0
- sase_github-0.1.0/tests/__init__.py +0 -0
- sase_github-0.1.0/tests/test_github_plugin.py +308 -0
|
@@ -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
|
+
[](https://github.com/astral-sh/ruff)
|
|
4
|
+
[](https://mypy-lang.org/)
|
|
5
|
+
[](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,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
|