sase-github 0.1.4__tar.gz → 0.1.6__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.6/.release-please-manifest.json +3 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/CHANGELOG.md +24 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/PKG-INFO +1 -1
- {sase_github-0.1.4 → sase_github-0.1.6}/README.md +40 -12
- {sase_github-0.1.4 → sase_github-0.1.6}/docs/architecture.md +7 -5
- sase_github-0.1.6/docs/configuration.md +114 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/pyproject.toml +1 -1
- sase_github-0.1.6/src/sase_github/config.py +88 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/plugin.py +5 -5
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/workspace_plugin.py +42 -15
- sase_github-0.1.6/tests/test_config.py +50 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_github_plugin.py +63 -12
- {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_submit_with_recorded_pr.py +6 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_workspace_plugin.py +215 -0
- sase_github-0.1.4/.release-please-manifest.json +0 -3
- sase_github-0.1.4/docs/configuration.md +0 -56
- sase_github-0.1.4/src/sase_github/config.py +0 -18
- {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/ci.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/pr-title.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/publish.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/beads.db +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/config.json +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/issues.jsonl +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/CLAUDE.md +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/Justfile +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/LICENSE +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/docs/xprompts.md +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/release-please-config.json +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/__init__.py +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/default_config.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/__init__.py +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/gh_setup.py +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/new_pr_desc_get_context.py +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/gh.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/new_pr_desc.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/pr_diff.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/prdd.yml +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/tests/__init__.py +0 -0
- {sase_github-0.1.4 → sase_github-0.1.6}/uv.lock +0 -0
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.6](https://github.com/sase-org/sase-github/compare/v0.1.5...v0.1.6) (2026-07-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* report closed GitHub PRs in submitted checks ([5ad2168](https://github.com/sase-org/sase-github/commit/5ad2168c1e36cca4b39e4435fff3076f3492dfb5))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* drop `--python 3.12` pin from install commands ([4ecc344](https://github.com/sase-org/sase-github/commit/4ecc344987dd7609996d8ee9040a31e4adb7bb96))
|
|
14
|
+
|
|
15
|
+
## [0.1.5](https://github.com/sase-org/sase-github/compare/v0.1.4...v0.1.5) (2026-07-01)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* support GitHub Enterprise hosts ([e1f3b02](https://github.com/sase-org/sase-github/commit/e1f3b02f469cbcbea9711697ffa3d7f5d0115597))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* document managed Enterprise setup ([baf704a](https://github.com/sase-org/sase-github/commit/baf704a8859a081a7d42ed6639504c3ee6354872))
|
|
26
|
+
|
|
3
27
|
## [0.1.4](https://github.com/sase-org/sase-github/compare/v0.1.3...v0.1.4) (2026-06-30)
|
|
4
28
|
|
|
5
29
|
|
|
@@ -8,23 +8,45 @@
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
10
|
**sase-github** is a plugin for [sase](https://github.com/sase-org/sase) that adds GitHub-specific VCS and workspace
|
|
11
|
-
support. It provides the `GitHubPlugin` VCS provider and `GitHubWorkspacePlugin` workspace provider for
|
|
12
|
-
|
|
13
|
-
GitHub-specific xprompt workflows.
|
|
11
|
+
support. It provides the `GitHubPlugin` VCS provider and `GitHubWorkspacePlugin` workspace provider for repositories
|
|
12
|
+
hosted on `github.com` or configured GitHub Enterprise hosts, integrating with the `gh` CLI for pull request creation,
|
|
13
|
+
management, and submission, along with GitHub-specific xprompt workflows.
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
|
+
For a managed SASE install, install `sase-github` into the same `uv tool` environment as `sase` so its entry points are
|
|
18
|
+
discovered by the `sase` command.
|
|
19
|
+
|
|
20
|
+
### Recommended: SASE Admin Center Updates tab
|
|
21
|
+
|
|
22
|
+
If SASE is already installed with `uv tool install sase`, open `sase ace`, press `#` for the SASE Admin Center, then go
|
|
23
|
+
to the **Updates** tab (`5`, or `[` / `]`). Highlight `sase-github` in the plugin list (`j` / `k`, or `/` to filter),
|
|
24
|
+
press `i` to install, and confirm the preview modal. The preview shows the exact `uv` command and resolved package set;
|
|
25
|
+
the install runs as a tracked background task and is discovered on the next `sase` run.
|
|
26
|
+
|
|
27
|
+
See the core SASE docs for the [Updates tab](https://github.com/sase-org/sase/blob/master/docs/configuration.md#updates-tab)
|
|
28
|
+
and [`sase plugin` commands](https://github.com/sase-org/sase/blob/master/docs/plugins.md).
|
|
29
|
+
|
|
30
|
+
### Alternative: install SASE and the plugin together
|
|
31
|
+
|
|
17
32
|
```bash
|
|
18
|
-
|
|
33
|
+
uv tool install sase --with sase-github
|
|
19
34
|
```
|
|
20
35
|
|
|
21
|
-
|
|
36
|
+
Repeat `--with` for additional plugins, for example `--with sase-github --with sase-telegram`. Add `--force` to replace
|
|
37
|
+
an existing tool install.
|
|
38
|
+
|
|
39
|
+
### Equivalent CLI for an existing install
|
|
22
40
|
|
|
23
41
|
```bash
|
|
24
|
-
|
|
42
|
+
sase plugin install github
|
|
25
43
|
```
|
|
26
44
|
|
|
27
|
-
|
|
45
|
+
`pip install sase-github` is only an escape hatch for non-managed or library-style environments. It is not the normal
|
|
46
|
+
path for a `uv tool`-managed SASE command.
|
|
47
|
+
|
|
48
|
+
Requires `sase>=0.1.3` as a dependency. For GitHub Enterprise Server or self-hosted GitHub, follow the
|
|
49
|
+
[GitHub Enterprise setup walkthrough](docs/configuration.md#github-enterprise-setup).
|
|
28
50
|
|
|
29
51
|
## What's Included
|
|
30
52
|
|
|
@@ -41,6 +63,8 @@ Requires `sase>=0.1.3` as a dependency (installed automatically).
|
|
|
41
63
|
|
|
42
64
|
### Configuration
|
|
43
65
|
|
|
66
|
+
- **`get_github_hosts()` / `get_default_github_host()`** — Read `github_hosts` from sase config to recognize GitHub
|
|
67
|
+
Enterprise hosts and choose the default host for `#gh(owner/repo)` clone refs
|
|
44
68
|
- **`get_github_orgs()`** — Reads `github_orgs` from sase config to determine SSH vs HTTPS clone URLs for
|
|
45
69
|
organizations/users with push access
|
|
46
70
|
|
|
@@ -61,15 +85,19 @@ itself with sase core:
|
|
|
61
85
|
- **`sase_workspace`** — Registers `GitHubWorkspacePlugin` as the `github` workspace provider
|
|
62
86
|
- **`sase_xprompts`** — Makes GitHub xprompts discoverable via plugin discovery
|
|
63
87
|
|
|
64
|
-
When sase detects a
|
|
65
|
-
`GitHubWorkspacePlugin` to handle VCS operations like PR creation, branch management, commit
|
|
66
|
-
submission.
|
|
88
|
+
When sase detects a repository whose remote origin host is in the configured GitHub host set, it automatically loads
|
|
89
|
+
`GitHubPlugin` and `GitHubWorkspacePlugin` to handle VCS operations like PR creation, branch management, commit
|
|
90
|
+
workflows, and PR submission.
|
|
67
91
|
|
|
68
92
|
## Requirements
|
|
69
93
|
|
|
70
94
|
- Python 3.12+
|
|
71
95
|
- [sase](https://github.com/sase-org/sase) >= 0.1.3
|
|
72
|
-
- [gh](https://cli.github.com/) CLI (for GitHub API operations)
|
|
96
|
+
- [gh](https://cli.github.com/) CLI (for GitHub API operations). For GitHub Enterprise, run
|
|
97
|
+
`gh auth login --hostname <host>` for each configured Enterprise host.
|
|
98
|
+
|
|
99
|
+
See [Configuration](docs/configuration.md) for `github_hosts`, `github_orgs`, workspace layout, and the ordered GitHub
|
|
100
|
+
Enterprise setup flow.
|
|
73
101
|
|
|
74
102
|
## Development
|
|
75
103
|
|
|
@@ -93,7 +121,7 @@ src/sase_github/
|
|
|
93
121
|
├── __init__.py # Package exports
|
|
94
122
|
├── plugin.py # GitHubPlugin VCS implementation
|
|
95
123
|
├── workspace_plugin.py # GitHubWorkspacePlugin workspace implementation
|
|
96
|
-
├── config.py # GitHub config helpers (org/user
|
|
124
|
+
├── config.py # GitHub config helpers (host and org/user lists)
|
|
97
125
|
├── scripts/
|
|
98
126
|
│ ├── gh_setup.py # Setup step for #gh workflow
|
|
99
127
|
│ └── new_pr_desc_get_context.py # Context retrieval for PR description generation
|
|
@@ -23,7 +23,7 @@ Extends `GitCommon` from sase core. Handles low-level VCS operations by wrapping
|
|
|
23
23
|
|
|
24
24
|
| Hook | Behavior |
|
|
25
25
|
| --------------------------- | ------------------------------------------------------------------------------------- |
|
|
26
|
-
| `vcs_classify_repo()` | Claims repos
|
|
26
|
+
| `vcs_classify_repo()` | Claims repos whose origin host is in the configured GitHub host set |
|
|
27
27
|
| `vcs_get_change_url()` | Returns PR URL via `gh pr view --json url` |
|
|
28
28
|
| `vcs_get_cl_number()` | Returns PR number via `gh pr view --json number` |
|
|
29
29
|
| `vcs_mail()` | Pushes branch (`git push -u origin`) and creates PR if needed (`gh pr create --fill`) |
|
|
@@ -57,8 +57,9 @@ The `resolve_gh_ref()` function supports three dispatch modes for `#gh` referenc
|
|
|
57
57
|
|
|
58
58
|
When the ref contains `/`, it's treated as a GitHub repo path:
|
|
59
59
|
|
|
60
|
-
- Derives workspace from `~/projects/github/<user>/<project>/`
|
|
61
|
-
|
|
60
|
+
- Derives workspace from `~/projects/github/<user>/<project>/` for `github.com`, or
|
|
61
|
+
`~/projects/github/<host>/<user>/<project>/` for other default hosts
|
|
62
|
+
- Clones the repo from the default GitHub host if it doesn't exist (SSH for orgs in `github_orgs`, HTTPS otherwise)
|
|
62
63
|
- Reuses an existing SASE ProjectSpec whose normalized `WORKSPACE_DIR` already points at that workspace
|
|
63
64
|
- Otherwise creates a canonical project name from the repo identity, normally `gh_<user>__<project>`
|
|
64
65
|
- Adds a deterministic suffix such as `-2` only if that canonical project name is already occupied by a different
|
|
@@ -104,5 +105,6 @@ When submitting a GitHub ChangeSpec (`ws_submit`):
|
|
|
104
105
|
|
|
105
106
|
## Config Helper
|
|
106
107
|
|
|
107
|
-
`config.py` provides `
|
|
108
|
-
|
|
108
|
+
`config.py` provides `get_github_hosts()` and `get_default_github_host()` for host matching and bare `#gh(owner/repo)`
|
|
109
|
+
clone refs. Configured hosts are normalized, and `github.com` is always included implicitly. It also provides
|
|
110
|
+
`get_github_orgs()`, which determines whether repos are cloned via SSH (for orgs the user has push access to) or HTTPS.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## GitHub Enterprise setup
|
|
4
|
+
|
|
5
|
+
Use this checklist for GitHub Enterprise Server or another self-hosted GitHub host:
|
|
6
|
+
|
|
7
|
+
1. **Install SASE and `sase-github`.** Use the
|
|
8
|
+
[README installation routes](../README.md#installation): the SASE Admin Center Updates tab is the recommended path
|
|
9
|
+
for an existing managed install, while `uv tool install sase --with sase-github` installs SASE and the
|
|
10
|
+
plugin together. The core SASE [plugin docs](https://github.com/sase-org/sase/blob/master/docs/plugins.md) also cover
|
|
11
|
+
`sase plugin install github` for existing installs.
|
|
12
|
+
2. **Authenticate `gh` to the Enterprise host.**
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
gh auth login --hostname github.mycompany.com
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Repo-scoped `gh` commands auto-detect the host from the git remote, so this host-specific login is enough for PR
|
|
19
|
+
operations once the repo is cloned.
|
|
20
|
+
3. **Set `github_hosts` in `~/.config/sase/sase.yml`.** **Put your Enterprise host first** if you want bare
|
|
21
|
+
`#gh(owner/repo)` refs to clone from Enterprise. `github.com` is always included implicitly, but the first configured
|
|
22
|
+
host is the default for bare refs; listing `github.com` first means bare refs default there instead. See
|
|
23
|
+
[`github_hosts`](#github_hosts).
|
|
24
|
+
4. **Optionally enable SSH clones.** Add owners to [`github_orgs`](#github_orgs) when you have an SSH key registered on
|
|
25
|
+
that GitHub host and want SASE to clone those repos with `git@<host>:owner/repo.git`. Owners not listed in
|
|
26
|
+
`github_orgs` use HTTPS.
|
|
27
|
+
5. **Verify and resolve a repo.** Run:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
sase doctor -C plugins.github
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then resolve a repo with `#gh(owner/repo)`. Enterprise workspaces are namespaced by host under
|
|
34
|
+
`~/projects/github/<host>/<user>/<project>/`; see [Workspace Layout](#workspace-layout).
|
|
35
|
+
|
|
36
|
+
## `github_hosts`
|
|
37
|
+
|
|
38
|
+
The `github_hosts` setting controls which GitHub hosts sase-github recognizes. Add it to your sase config file
|
|
39
|
+
(`~/.config/sase/sase.yml`) when you use GitHub Enterprise Server or another self-hosted GitHub instance:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
github_hosts:
|
|
43
|
+
- github.mycompany.com
|
|
44
|
+
- github.com
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Effect:** sase-github claims repositories whose remote origin host matches one of these hosts. `github.com` is always
|
|
48
|
+
included implicitly, so public GitHub keeps working even if you only configure an Enterprise host.
|
|
49
|
+
|
|
50
|
+
The first configured host is the default for bare `#gh(owner/repo)` refs. With the example above,
|
|
51
|
+
`#gh(my-org/my-repo)` clones from `github.mycompany.com`. If `github_hosts` is unset, the default host is `github.com`.
|
|
52
|
+
|
|
53
|
+
Host entries are normalized, so pasted values such as `https://github.mycompany.com/` are accepted.
|
|
54
|
+
|
|
55
|
+
## `github_orgs`
|
|
56
|
+
|
|
57
|
+
The `github_orgs` setting controls how sase-github clones repositories. Add it to your sase config file
|
|
58
|
+
(`~/.config/sase/sase.yml`):
|
|
59
|
+
|
|
60
|
+
```yaml
|
|
61
|
+
github_orgs:
|
|
62
|
+
- your-username
|
|
63
|
+
- your-org
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Effect:** When cloning a repo whose owner is in this list, sase-github uses SSH
|
|
67
|
+
(`git@<github-host>:user/project.git`). For all other repos, it uses HTTPS
|
|
68
|
+
(`https://<github-host>/user/project.git`).
|
|
69
|
+
|
|
70
|
+
This matters because SSH URLs require an SSH key configured with GitHub, while HTTPS URLs work for public repos without
|
|
71
|
+
authentication (but require a token for push access).
|
|
72
|
+
|
|
73
|
+
## Default Config
|
|
74
|
+
|
|
75
|
+
sase-github contributes a `default_config.yml` via the `sase_config` entry point. This is merged into the sase config
|
|
76
|
+
chain between sase core defaults and your user config.
|
|
77
|
+
|
|
78
|
+
Currently the default config defines:
|
|
79
|
+
|
|
80
|
+
- `xprompts.pr_diff` — an xprompt that expands to the diff of the current PR's changes
|
|
81
|
+
|
|
82
|
+
## Requirements
|
|
83
|
+
|
|
84
|
+
- **`gh` CLI** — Required for all PR operations. Install from https://cli.github.com/ and authenticate with
|
|
85
|
+
`gh auth login`. For GitHub Enterprise, authenticate to the configured host with
|
|
86
|
+
`gh auth login --hostname github.mycompany.com`.
|
|
87
|
+
- **Git** — Standard git CLI for repository operations.
|
|
88
|
+
|
|
89
|
+
## Workspace Layout
|
|
90
|
+
|
|
91
|
+
Primary GitHub workspaces are stored under `~/projects/github/<user>/<project>/` when first resolved from a
|
|
92
|
+
`#gh(user/project)` reference and the default host is `github.com`. For other default hosts, workspaces are namespaced
|
|
93
|
+
by host at `~/projects/github/<host>/<user>/<project>/` to avoid collisions between same-named repos on different
|
|
94
|
+
GitHub installations.
|
|
95
|
+
|
|
96
|
+
Numbered parallel-work checkouts follow SASE's shared `workspace.root` policy: by default they live under the platform
|
|
97
|
+
state-root namespace, while explicit `workspace.root: adjacent` keeps the legacy
|
|
98
|
+
`~/projects/github/<user>/<project>_<N>/` sibling layout for `github.com` projects.
|
|
99
|
+
|
|
100
|
+
## Project Files
|
|
101
|
+
|
|
102
|
+
Project metadata is stored in `~/.sase/projects/<project>/<project>.sase`; legacy `.gp` files remain readable as a
|
|
103
|
+
fallback. The key field is `WORKSPACE_DIR`, which points to the primary workspace directory and is set automatically
|
|
104
|
+
when you first use an `#gh:<user>/<project>` ref.
|
|
105
|
+
|
|
106
|
+
For new `owner/repo` refs, the project name is based on the full GitHub identity, normally `gh_<user>__<project>`, so
|
|
107
|
+
two owners can have repositories with the same basename. If that canonical name is already occupied by a different
|
|
108
|
+
project, `PROJECT_NAME`, or alias, sase-github adds a deterministic suffix such as `-2`.
|
|
109
|
+
|
|
110
|
+
sase-github also writes `PROJECT_NAME` to the repo basename when it is valid and useful. The first `owner/foo` repo can
|
|
111
|
+
get `PROJECT_NAME: foo`; a second `owner/foo` repo gets the next available display name such as `foo_1`. Existing
|
|
112
|
+
basename ProjectSpecs are reused when their `WORKSPACE_DIR` already matches the GitHub workspace, so no automatic
|
|
113
|
+
migration or rename is required. Existing auto-aliased GitHub projects are also left unchanged and keep resolving via
|
|
114
|
+
their `PROJECT_ALIASES` entry.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""GitHub configuration helpers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
from sase.config import load_merged_config
|
|
8
|
+
|
|
9
|
+
DEFAULT_GITHUB_HOST = "github.com"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalize_github_host(value: object) -> str | None:
|
|
13
|
+
"""Normalize a configured GitHub host or pasted GitHub URL."""
|
|
14
|
+
if value is None:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
raw = str(value).strip().lower().rstrip("/")
|
|
18
|
+
if not raw:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
# Accept pasted scp-style remotes such as git@github.example.com:org/repo.git.
|
|
22
|
+
scp_match = re.match(r"^(?:[^@/]+@)?([^:/]+):[^/]+/.+", raw)
|
|
23
|
+
if "://" not in raw and scp_match:
|
|
24
|
+
return scp_match.group(1)
|
|
25
|
+
|
|
26
|
+
if "://" in raw:
|
|
27
|
+
parsed = urlparse(raw)
|
|
28
|
+
host = parsed.netloc.rsplit("@", 1)[-1]
|
|
29
|
+
else:
|
|
30
|
+
host = raw.split("/", 1)[0].rsplit("@", 1)[-1]
|
|
31
|
+
|
|
32
|
+
return host or None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _config_list(value: object) -> list[object]:
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return value
|
|
38
|
+
if isinstance(value, tuple):
|
|
39
|
+
return list(value)
|
|
40
|
+
if isinstance(value, Iterable) and not isinstance(value, str):
|
|
41
|
+
return list(value)
|
|
42
|
+
if value:
|
|
43
|
+
return [value]
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _dedupe_hosts(hosts: Iterable[str]) -> list[str]:
|
|
48
|
+
deduped: list[str] = []
|
|
49
|
+
for host in hosts:
|
|
50
|
+
if host not in deduped:
|
|
51
|
+
deduped.append(host)
|
|
52
|
+
return deduped
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_github_orgs() -> list[str]:
|
|
56
|
+
"""Read ``github_orgs`` from the merged sase config.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
A list of GitHub org/user names the user has push access to.
|
|
60
|
+
"""
|
|
61
|
+
config = load_merged_config()
|
|
62
|
+
value = config.get("github_orgs")
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
return [str(v) for v in value if v]
|
|
65
|
+
if value:
|
|
66
|
+
return [str(value)]
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_github_hosts() -> list[str]:
|
|
71
|
+
"""Read configured GitHub hosts, always including ``github.com``."""
|
|
72
|
+
config = load_merged_config()
|
|
73
|
+
configured_hosts = [
|
|
74
|
+
host
|
|
75
|
+
for item in _config_list(config.get("github_hosts"))
|
|
76
|
+
if (host := normalize_github_host(item)) is not None
|
|
77
|
+
]
|
|
78
|
+
return _dedupe_hosts([*configured_hosts, DEFAULT_GITHUB_HOST])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_default_github_host() -> str:
|
|
82
|
+
"""Return the host used for bare ``#gh(owner/repo)`` clone refs."""
|
|
83
|
+
config = load_merged_config()
|
|
84
|
+
for item in _config_list(config.get("github_hosts")):
|
|
85
|
+
host = normalize_github_host(item)
|
|
86
|
+
if host:
|
|
87
|
+
return host
|
|
88
|
+
return DEFAULT_GITHUB_HOST
|
|
@@ -10,6 +10,7 @@ import subprocess
|
|
|
10
10
|
|
|
11
11
|
from sase.vcs_provider._hookspec import hookimpl
|
|
12
12
|
from sase.vcs_provider.plugins._git_common import GitCommon
|
|
13
|
+
from sase_github.config import get_github_hosts, normalize_github_host
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class GitHubPlugin(GitCommon):
|
|
@@ -17,7 +18,7 @@ class GitHubPlugin(GitCommon):
|
|
|
17
18
|
|
|
18
19
|
@hookimpl
|
|
19
20
|
def vcs_classify_repo(self, git_dir: str) -> str | None:
|
|
20
|
-
"""Claim repos
|
|
21
|
+
"""Claim repos whose origin host is a configured GitHub host."""
|
|
21
22
|
try:
|
|
22
23
|
result = subprocess.run(
|
|
23
24
|
["git", "config", "--get", "remote.origin.url"],
|
|
@@ -34,7 +35,8 @@ class GitHubPlugin(GitCommon):
|
|
|
34
35
|
return None
|
|
35
36
|
|
|
36
37
|
url = result.stdout.strip()
|
|
37
|
-
|
|
38
|
+
host = normalize_github_host(url)
|
|
39
|
+
if host in get_github_hosts():
|
|
38
40
|
return "github"
|
|
39
41
|
return None
|
|
40
42
|
|
|
@@ -65,9 +67,7 @@ class GitHubPlugin(GitCommon):
|
|
|
65
67
|
return (True, None)
|
|
66
68
|
|
|
67
69
|
@hookimpl
|
|
68
|
-
def vcs_get_change_body(
|
|
69
|
-
self, change_ref: str, cwd: str
|
|
70
|
-
) -> tuple[bool, str | None]:
|
|
70
|
+
def vcs_get_change_body(self, change_ref: str, cwd: str) -> tuple[bool, str | None]:
|
|
71
71
|
out = self._run(
|
|
72
72
|
["gh", "pr", "view", change_ref, "--json", "body", "-q", ".body"], cwd
|
|
73
73
|
)
|
|
@@ -25,6 +25,9 @@ from sase.workspace_provider.utils import (
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from sase.core.project_lifecycle_wire import ProjectRecordWire
|
|
27
27
|
|
|
28
|
+
_PR_URL_RE = re.compile(r"https?://[^/]+/.+?/pull/(\d+)")
|
|
29
|
+
_HOSTED_URL_RE = re.compile(r"https?://[^/]+/")
|
|
30
|
+
|
|
28
31
|
|
|
29
32
|
class GitHubWorkspacePlugin:
|
|
30
33
|
"""Workspace provider plugin for GitHub-hosted projects."""
|
|
@@ -96,7 +99,7 @@ class GitHubWorkspacePlugin:
|
|
|
96
99
|
@hookimpl
|
|
97
100
|
def ws_extract_change_identifier(self, cl_url: str) -> tuple[str, str] | None:
|
|
98
101
|
"""Extract PR number from a GitHub PR URL."""
|
|
99
|
-
match =
|
|
102
|
+
match = _PR_URL_RE.match(cl_url)
|
|
100
103
|
if match:
|
|
101
104
|
return (match.group(1), "git")
|
|
102
105
|
return None
|
|
@@ -105,18 +108,24 @@ class GitHubWorkspacePlugin:
|
|
|
105
108
|
def ws_generate_submitted_check_script(
|
|
106
109
|
self, identifier: str, vcs_type: str
|
|
107
110
|
) -> str | None:
|
|
108
|
-
"""Generate script to check if a GitHub PR is merged."""
|
|
111
|
+
"""Generate script to check if a GitHub PR is merged or closed."""
|
|
109
112
|
if vcs_type != "git":
|
|
110
113
|
return None
|
|
111
114
|
return (
|
|
112
115
|
f"state=$(gh pr view {identifier} --json state -q '.state' 2>/dev/null)\n"
|
|
113
|
-
|
|
116
|
+
'echo "PR state: ${state:-<unavailable>}"\n'
|
|
117
|
+
'case "$state" in\n'
|
|
118
|
+
" MERGED) true ;;\n"
|
|
119
|
+
" # Keep this literal in sync with SUBMITTED_CHECK_EXIT_CODE_CLOSED.\n"
|
|
120
|
+
" CLOSED) (exit 20) ;;\n"
|
|
121
|
+
" *) false ;;\n"
|
|
122
|
+
"esac"
|
|
114
123
|
)
|
|
115
124
|
|
|
116
125
|
@hookimpl
|
|
117
126
|
def ws_supports_reviewer_comments(self, cl_url: str) -> bool | None:
|
|
118
127
|
"""GitHub does not support reviewer comments via critique_comments."""
|
|
119
|
-
if
|
|
128
|
+
if _HOSTED_URL_RE.match(cl_url):
|
|
120
129
|
return False
|
|
121
130
|
return None
|
|
122
131
|
|
|
@@ -314,8 +323,7 @@ class GitHubWorkspacePlugin:
|
|
|
314
323
|
return _submit_via_pr_merge(changespec, ws_dir, rich_console)
|
|
315
324
|
return (
|
|
316
325
|
False,
|
|
317
|
-
"GitHub project has no PR for this branch. "
|
|
318
|
-
"Create a PR first with #pr.",
|
|
326
|
+
"GitHub project has no PR for this branch. Create a PR first with #pr.",
|
|
319
327
|
)
|
|
320
328
|
finally:
|
|
321
329
|
release_workspace(
|
|
@@ -331,15 +339,25 @@ class GitHubWorkspacePlugin:
|
|
|
331
339
|
# ── Private helpers ─────────────────────────────────────────────────
|
|
332
340
|
|
|
333
341
|
|
|
334
|
-
def _clone_gh_repo(
|
|
342
|
+
def _clone_gh_repo(
|
|
343
|
+
user: str,
|
|
344
|
+
project: str,
|
|
345
|
+
target_dir: str,
|
|
346
|
+
*,
|
|
347
|
+
host: str | None = None,
|
|
348
|
+
) -> None:
|
|
335
349
|
"""Clone a GitHub repo to the target directory."""
|
|
336
|
-
from sase_github.config import get_github_orgs
|
|
350
|
+
from sase_github.config import get_default_github_host, get_github_orgs
|
|
337
351
|
|
|
352
|
+
github_host = host or get_default_github_host()
|
|
338
353
|
gh_orgs = get_github_orgs()
|
|
339
354
|
if user in gh_orgs:
|
|
340
|
-
|
|
355
|
+
if ":" in github_host:
|
|
356
|
+
url = f"ssh://git@{github_host}/{user}/{project}.git"
|
|
357
|
+
else:
|
|
358
|
+
url = f"git@{github_host}:{user}/{project}.git"
|
|
341
359
|
else:
|
|
342
|
-
url = f"https://
|
|
360
|
+
url = f"https://{github_host}/{user}/{project}.git"
|
|
343
361
|
parent = os.path.dirname(target_dir.rstrip("/"))
|
|
344
362
|
os.makedirs(parent, exist_ok=True)
|
|
345
363
|
|
|
@@ -361,8 +379,14 @@ def _projects_base() -> Path:
|
|
|
361
379
|
return Path.home() / ".sase" / "projects"
|
|
362
380
|
|
|
363
381
|
|
|
364
|
-
def _github_workspace_dir(user: str, project: str) -> str:
|
|
365
|
-
|
|
382
|
+
def _github_workspace_dir(user: str, project: str, host: str | None = None) -> str:
|
|
383
|
+
from sase_github.config import DEFAULT_GITHUB_HOST, get_default_github_host
|
|
384
|
+
|
|
385
|
+
github_host = host or get_default_github_host()
|
|
386
|
+
base = Path.home() / "projects" / "github"
|
|
387
|
+
if github_host == DEFAULT_GITHUB_HOST:
|
|
388
|
+
return str(base / user / project) + "/"
|
|
389
|
+
return str(base / github_host / user / project) + "/"
|
|
366
390
|
|
|
367
391
|
|
|
368
392
|
def _normalized_workspace_dir(workspace_dir: str | None) -> str | None:
|
|
@@ -506,8 +530,11 @@ def _resolved_ref_for_record(
|
|
|
506
530
|
|
|
507
531
|
|
|
508
532
|
def _resolve_repo_path_ref(user: str, project: str) -> ResolvedRef:
|
|
533
|
+
from sase_github.config import get_default_github_host
|
|
534
|
+
|
|
509
535
|
projects_base = _projects_base()
|
|
510
|
-
|
|
536
|
+
github_host = get_default_github_host()
|
|
537
|
+
primary_workspace_dir = _github_workspace_dir(user, project, host=github_host)
|
|
511
538
|
records = _list_project_records(projects_base)
|
|
512
539
|
existing_record = _find_project_record_for_workspace(
|
|
513
540
|
records,
|
|
@@ -515,7 +542,7 @@ def _resolve_repo_path_ref(user: str, project: str) -> ResolvedRef:
|
|
|
515
542
|
)
|
|
516
543
|
|
|
517
544
|
if not os.path.isdir(primary_workspace_dir.rstrip("/")):
|
|
518
|
-
_clone_gh_repo(user, project, primary_workspace_dir)
|
|
545
|
+
_clone_gh_repo(user, project, primary_workspace_dir, host=github_host)
|
|
519
546
|
|
|
520
547
|
if existing_record is None:
|
|
521
548
|
project_name = _allocate_canonical_project_name(user, project, records)
|
|
@@ -612,7 +639,7 @@ def _extract_pr_number(cl_url: str | None) -> str | None:
|
|
|
612
639
|
"""Extract a PR number from a GitHub PR URL, or return ``None``."""
|
|
613
640
|
if not cl_url:
|
|
614
641
|
return None
|
|
615
|
-
match =
|
|
642
|
+
match = _PR_URL_RE.match(cl_url)
|
|
616
643
|
return match.group(1) if match else None
|
|
617
644
|
|
|
618
645
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Tests for GitHub configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from sase_github.config import (
|
|
6
|
+
get_default_github_host,
|
|
7
|
+
get_github_hosts,
|
|
8
|
+
normalize_github_host,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_get_github_hosts_defaults_to_github_com() -> None:
|
|
13
|
+
with patch("sase_github.config.load_merged_config", return_value={}):
|
|
14
|
+
assert get_github_hosts() == ["github.com"]
|
|
15
|
+
assert get_default_github_host() == "github.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_get_github_hosts_normalizes_and_adds_github_com() -> None:
|
|
19
|
+
with patch(
|
|
20
|
+
"sase_github.config.load_merged_config",
|
|
21
|
+
return_value={
|
|
22
|
+
"github_hosts": [
|
|
23
|
+
" https://GITHUB.ENTERPRISE.TEST/ ",
|
|
24
|
+
"https://github.enterprise.test/org/repo",
|
|
25
|
+
"github.com",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
):
|
|
29
|
+
assert get_github_hosts() == ["github.enterprise.test", "github.com"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_get_default_github_host_uses_first_configured_host() -> None:
|
|
33
|
+
with patch(
|
|
34
|
+
"sase_github.config.load_merged_config",
|
|
35
|
+
return_value={"github_hosts": ["github.enterprise.test", "github.com"]},
|
|
36
|
+
):
|
|
37
|
+
assert get_default_github_host() == "github.enterprise.test"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_normalize_github_host_accepts_urls_and_remotes() -> None:
|
|
41
|
+
assert normalize_github_host("https://GITHUB.ENTERPRISE.TEST/org/repo") == (
|
|
42
|
+
"github.enterprise.test"
|
|
43
|
+
)
|
|
44
|
+
assert normalize_github_host("git@github.enterprise.test:org/repo.git") == (
|
|
45
|
+
"github.enterprise.test"
|
|
46
|
+
)
|
|
47
|
+
assert normalize_github_host("ssh://git@github.enterprise.test/org/repo.git") == (
|
|
48
|
+
"github.enterprise.test"
|
|
49
|
+
)
|
|
50
|
+
assert normalize_github_host("") is None
|
|
@@ -42,6 +42,65 @@ def test_github_plugin_is_command_runner() -> None:
|
|
|
42
42
|
assert isinstance(plugin, CommandRunner)
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# === Tests for repository classification ===
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _classify_origin_url(url: str, hosts: list[str]) -> str | None:
|
|
49
|
+
plugin = GitHubPlugin()
|
|
50
|
+
with (
|
|
51
|
+
patch("sase_github.plugin.get_github_hosts", return_value=hosts),
|
|
52
|
+
patch("sase_github.plugin.subprocess.run") as mock_run,
|
|
53
|
+
):
|
|
54
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=f"{url}\n", stderr="")
|
|
55
|
+
return plugin.vcs_classify_repo("/workspace")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.parametrize(
|
|
59
|
+
"url",
|
|
60
|
+
[
|
|
61
|
+
"https://github.com/user/repo.git",
|
|
62
|
+
"git@github.com:user/repo.git",
|
|
63
|
+
"ssh://git@github.com/user/repo.git",
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
def test_vcs_classify_repo_github_com_url_forms(url: str) -> None:
|
|
67
|
+
assert _classify_origin_url(url, ["github.com"]) == "github"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.parametrize(
|
|
71
|
+
"url",
|
|
72
|
+
[
|
|
73
|
+
"https://github.enterprise.test/user/repo.git",
|
|
74
|
+
"git@github.enterprise.test:user/repo.git",
|
|
75
|
+
"ssh://git@github.enterprise.test/user/repo.git",
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
def test_vcs_classify_repo_configured_enterprise_url_forms(url: str) -> None:
|
|
79
|
+
assert _classify_origin_url(url, ["github.enterprise.test", "github.com"]) == (
|
|
80
|
+
"github"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_vcs_classify_repo_unconfigured_host_returns_none() -> None:
|
|
85
|
+
assert (
|
|
86
|
+
_classify_origin_url(
|
|
87
|
+
"https://github.other.test/user/repo.git",
|
|
88
|
+
["github.enterprise.test", "github.com"],
|
|
89
|
+
)
|
|
90
|
+
is None
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_vcs_classify_repo_uses_host_equality_not_substring() -> None:
|
|
95
|
+
assert (
|
|
96
|
+
_classify_origin_url(
|
|
97
|
+
"https://example.test/github.com/user/repo.git",
|
|
98
|
+
["github.com"],
|
|
99
|
+
)
|
|
100
|
+
is None
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
45
104
|
# === Tests for core git operations via plugin ===
|
|
46
105
|
|
|
47
106
|
|
|
@@ -118,9 +177,7 @@ def test_plugin_abandon_change_already_closed(
|
|
|
118
177
|
mock_run: MagicMock, github_provider: VCSPluginManager
|
|
119
178
|
) -> None:
|
|
120
179
|
"""abandon_change succeeds when PR is already closed."""
|
|
121
|
-
mock_run.return_value = MagicMock(
|
|
122
|
-
returncode=1, stdout="", stderr="already closed"
|
|
123
|
-
)
|
|
180
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="already closed")
|
|
124
181
|
success, error = github_provider.abandon_change(
|
|
125
182
|
"https://github.com/user/repo/pull/42", "feature-branch", "/workspace"
|
|
126
183
|
)
|
|
@@ -134,9 +191,7 @@ def test_plugin_abandon_change_not_found(
|
|
|
134
191
|
mock_run: MagicMock, github_provider: VCSPluginManager
|
|
135
192
|
) -> None:
|
|
136
193
|
"""abandon_change succeeds when PR is not found."""
|
|
137
|
-
mock_run.return_value = MagicMock(
|
|
138
|
-
returncode=1, stdout="", stderr="not found"
|
|
139
|
-
)
|
|
194
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="not found")
|
|
140
195
|
success, error = github_provider.abandon_change(
|
|
141
196
|
"https://github.com/user/repo/pull/42", "feature-branch", "/workspace"
|
|
142
197
|
)
|
|
@@ -150,9 +205,7 @@ def test_plugin_abandon_change_failure(
|
|
|
150
205
|
mock_run: MagicMock, github_provider: VCSPluginManager
|
|
151
206
|
) -> None:
|
|
152
207
|
"""abandon_change returns error on unexpected failure."""
|
|
153
|
-
mock_run.return_value = MagicMock(
|
|
154
|
-
returncode=1, stdout="", stderr="network error"
|
|
155
|
-
)
|
|
208
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="network error")
|
|
156
209
|
success, error = github_provider.abandon_change(
|
|
157
210
|
"https://github.com/user/repo/pull/42", "feature-branch", "/workspace"
|
|
158
211
|
)
|
|
@@ -520,9 +573,7 @@ def test_direct_abandon_change_success(mock_run: MagicMock) -> None:
|
|
|
520
573
|
@patch(_MOCK_TARGET)
|
|
521
574
|
def test_direct_abandon_change_already_closed(mock_run: MagicMock) -> None:
|
|
522
575
|
"""Test GitHubPlugin.vcs_abandon_change when PR is already closed."""
|
|
523
|
-
mock_run.return_value = MagicMock(
|
|
524
|
-
returncode=1, stdout="", stderr="already closed"
|
|
525
|
-
)
|
|
576
|
+
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="already closed")
|
|
526
577
|
|
|
527
578
|
plugin = GitHubPlugin()
|
|
528
579
|
success, error = plugin.vcs_abandon_change(
|
|
@@ -23,6 +23,12 @@ def test_extract_pr_number_from_url() -> None:
|
|
|
23
23
|
assert _extract_pr_number("https://github.com/org/repo/pull/42") == "42"
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def test_extract_pr_number_from_enterprise_url() -> None:
|
|
27
|
+
assert _extract_pr_number("https://github.enterprise.test/org/repo/pull/42") == (
|
|
28
|
+
"42"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
26
32
|
def test_extract_pr_number_none() -> None:
|
|
27
33
|
assert _extract_pr_number(None) is None
|
|
28
34
|
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
"""Tests for sase_github.workspace_plugin module (GitHub-specific functions)."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import subprocess
|
|
4
5
|
import tempfile
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from unittest.mock import MagicMock, patch
|
|
7
8
|
|
|
8
9
|
import pytest
|
|
10
|
+
from sase.workspace_provider import SUBMITTED_CHECK_EXIT_CODE_CLOSED
|
|
9
11
|
|
|
10
12
|
from sase_github.workspace_plugin import (
|
|
11
13
|
GitHubWorkspacePlugin,
|
|
14
|
+
_clone_gh_repo,
|
|
15
|
+
_extract_pr_number,
|
|
16
|
+
_github_workspace_dir,
|
|
12
17
|
resolve_gh_ref,
|
|
13
18
|
)
|
|
14
19
|
|
|
@@ -34,6 +39,191 @@ def _home_patches(home: Path) -> tuple[object, object]:
|
|
|
34
39
|
)
|
|
35
40
|
|
|
36
41
|
|
|
42
|
+
def _run_submitted_check_script(
|
|
43
|
+
tmp_path: Path, gh_body: str
|
|
44
|
+
) -> subprocess.CompletedProcess[str]:
|
|
45
|
+
bin_dir = tmp_path / "bin"
|
|
46
|
+
bin_dir.mkdir()
|
|
47
|
+
gh = bin_dir / "gh"
|
|
48
|
+
gh.write_text(f"#!/bin/bash\n{gh_body}\n", encoding="utf-8")
|
|
49
|
+
gh.chmod(0o755)
|
|
50
|
+
|
|
51
|
+
script = GitHubWorkspacePlugin().ws_generate_submitted_check_script("42", "git")
|
|
52
|
+
assert script is not None
|
|
53
|
+
|
|
54
|
+
script_path = tmp_path / "check.sh"
|
|
55
|
+
script_path.write_text(f"#!/bin/bash\n{script}\n", encoding="utf-8")
|
|
56
|
+
script_path.chmod(0o755)
|
|
57
|
+
|
|
58
|
+
env = {
|
|
59
|
+
**os.environ,
|
|
60
|
+
"PATH": f"{bin_dir}:{os.environ['PATH']}",
|
|
61
|
+
}
|
|
62
|
+
return subprocess.run(
|
|
63
|
+
[str(script_path)],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
check=False,
|
|
67
|
+
env=env,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture(autouse=True)
|
|
72
|
+
def _default_github_host() -> object:
|
|
73
|
+
with patch("sase_github.config.get_default_github_host", return_value="github.com"):
|
|
74
|
+
yield
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize(
|
|
78
|
+
("gh_body", "expected_code", "expected_state"),
|
|
79
|
+
[
|
|
80
|
+
("printf 'MERGED\\n'", 0, "MERGED"),
|
|
81
|
+
("printf 'CLOSED\\n'", SUBMITTED_CHECK_EXIT_CODE_CLOSED, "CLOSED"),
|
|
82
|
+
("printf 'OPEN\\n'", 1, "OPEN"),
|
|
83
|
+
("exit 2", 1, "<unavailable>"),
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
def test_submitted_check_script_reports_pr_state(
|
|
87
|
+
tmp_path: Path,
|
|
88
|
+
gh_body: str,
|
|
89
|
+
expected_code: int,
|
|
90
|
+
expected_state: str,
|
|
91
|
+
) -> None:
|
|
92
|
+
result = _run_submitted_check_script(tmp_path, gh_body)
|
|
93
|
+
|
|
94
|
+
assert result.returncode == expected_code
|
|
95
|
+
assert f"PR state: {expected_state}" in result.stdout
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_submitted_check_script_has_no_bare_exit_statement() -> None:
|
|
99
|
+
script = GitHubWorkspacePlugin().ws_generate_submitted_check_script("42", "git")
|
|
100
|
+
assert script is not None
|
|
101
|
+
|
|
102
|
+
for line in script.splitlines():
|
|
103
|
+
stripped = line.strip()
|
|
104
|
+
assert stripped != "exit"
|
|
105
|
+
assert not stripped.startswith("exit ")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_submitted_check_closed_literal_matches_sase_contract() -> None:
|
|
109
|
+
script = GitHubWorkspacePlugin().ws_generate_submitted_check_script("42", "git")
|
|
110
|
+
assert script is not None
|
|
111
|
+
assert f"(exit {SUBMITTED_CHECK_EXIT_CODE_CLOSED})" in script
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TestHostAwareWorkspace:
|
|
115
|
+
def test_github_com_workspace_path_is_unchanged(self) -> None:
|
|
116
|
+
with tempfile.TemporaryDirectory() as d:
|
|
117
|
+
home = Path(d)
|
|
118
|
+
with patch("sase_github.workspace_plugin.Path.home", return_value=home):
|
|
119
|
+
assert _github_workspace_dir("alice", "repo", host="github.com") == (
|
|
120
|
+
str(home / "projects" / "github" / "alice" / "repo") + "/"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def test_enterprise_workspace_path_is_namespaced_by_host(self) -> None:
|
|
124
|
+
with tempfile.TemporaryDirectory() as d:
|
|
125
|
+
home = Path(d)
|
|
126
|
+
with patch("sase_github.workspace_plugin.Path.home", return_value=home):
|
|
127
|
+
assert _github_workspace_dir(
|
|
128
|
+
"alice",
|
|
129
|
+
"repo",
|
|
130
|
+
host="github.enterprise.test",
|
|
131
|
+
) == (
|
|
132
|
+
str(
|
|
133
|
+
home
|
|
134
|
+
/ "projects"
|
|
135
|
+
/ "github"
|
|
136
|
+
/ "github.enterprise.test"
|
|
137
|
+
/ "alice"
|
|
138
|
+
/ "repo"
|
|
139
|
+
)
|
|
140
|
+
+ "/"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def test_clone_uses_enterprise_https_url(self) -> None:
|
|
144
|
+
with tempfile.TemporaryDirectory() as d:
|
|
145
|
+
target = str(Path(d) / "repo")
|
|
146
|
+
with (
|
|
147
|
+
patch("sase_github.config.get_github_orgs", return_value=[]),
|
|
148
|
+
patch("sase_github.workspace_plugin.subprocess.run") as mock_run,
|
|
149
|
+
):
|
|
150
|
+
_clone_gh_repo(
|
|
151
|
+
"alice",
|
|
152
|
+
"repo",
|
|
153
|
+
target,
|
|
154
|
+
host="github.enterprise.test",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert mock_run.call_args[0][0] == [
|
|
158
|
+
"git",
|
|
159
|
+
"clone",
|
|
160
|
+
"https://github.enterprise.test/alice/repo.git",
|
|
161
|
+
target,
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
def test_clone_uses_enterprise_ssh_url_for_configured_org(self) -> None:
|
|
165
|
+
with tempfile.TemporaryDirectory() as d:
|
|
166
|
+
target = str(Path(d) / "repo")
|
|
167
|
+
with (
|
|
168
|
+
patch("sase_github.config.get_github_orgs", return_value=["alice"]),
|
|
169
|
+
patch("sase_github.workspace_plugin.subprocess.run") as mock_run,
|
|
170
|
+
):
|
|
171
|
+
_clone_gh_repo(
|
|
172
|
+
"alice",
|
|
173
|
+
"repo",
|
|
174
|
+
target,
|
|
175
|
+
host="github.enterprise.test",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
assert mock_run.call_args[0][0] == [
|
|
179
|
+
"git",
|
|
180
|
+
"clone",
|
|
181
|
+
"git@github.enterprise.test:alice/repo.git",
|
|
182
|
+
target,
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
@patch(
|
|
186
|
+
"sase_github.workspace_plugin.get_default_branch", return_value="origin/main"
|
|
187
|
+
)
|
|
188
|
+
def test_repo_path_uses_default_enterprise_host(
|
|
189
|
+
self,
|
|
190
|
+
mock_branch: MagicMock,
|
|
191
|
+
) -> None:
|
|
192
|
+
with tempfile.TemporaryDirectory() as d:
|
|
193
|
+
home = Path(d)
|
|
194
|
+
path_patch, env_patch = _home_patches(home)
|
|
195
|
+
with (
|
|
196
|
+
path_patch,
|
|
197
|
+
env_patch,
|
|
198
|
+
patch(
|
|
199
|
+
"sase_github.config.get_default_github_host",
|
|
200
|
+
return_value="github.enterprise.test",
|
|
201
|
+
),
|
|
202
|
+
patch("sase_github.config.get_github_orgs", return_value=[]),
|
|
203
|
+
patch("sase_github.workspace_plugin.subprocess.run") as mock_run,
|
|
204
|
+
):
|
|
205
|
+
result = resolve_gh_ref("alice/repo")
|
|
206
|
+
|
|
207
|
+
expected = (
|
|
208
|
+
str(
|
|
209
|
+
home
|
|
210
|
+
/ "projects"
|
|
211
|
+
/ "github"
|
|
212
|
+
/ "github.enterprise.test"
|
|
213
|
+
/ "alice"
|
|
214
|
+
/ "repo"
|
|
215
|
+
)
|
|
216
|
+
+ "/"
|
|
217
|
+
)
|
|
218
|
+
assert result.primary_workspace_dir == expected
|
|
219
|
+
assert mock_run.call_args[0][0] == [
|
|
220
|
+
"git",
|
|
221
|
+
"clone",
|
|
222
|
+
"https://github.enterprise.test/alice/repo.git",
|
|
223
|
+
expected.rstrip("/"),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
|
|
37
227
|
class TestResolveGhRef:
|
|
38
228
|
@patch(
|
|
39
229
|
"sase_github.workspace_plugin.get_default_branch", return_value="origin/main"
|
|
@@ -360,3 +550,28 @@ class TestDetectWorkflowTypeForProject:
|
|
|
360
550
|
with open(spec, "w") as f:
|
|
361
551
|
f.write(f"WORKSPACE_DIR: {workspace}\nNAME: cl\n")
|
|
362
552
|
assert plugin.ws_detect_workflow_type(project_file=spec) == "gh"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class TestPrUrlParsing:
|
|
556
|
+
def test_extract_change_identifier_accepts_enterprise_pr_url(self) -> None:
|
|
557
|
+
plugin = GitHubWorkspacePlugin()
|
|
558
|
+
|
|
559
|
+
assert plugin.ws_extract_change_identifier(
|
|
560
|
+
"https://github.enterprise.test/user/repo/pull/42"
|
|
561
|
+
) == ("42", "git")
|
|
562
|
+
|
|
563
|
+
def test_extract_pr_number_accepts_enterprise_pr_url(self) -> None:
|
|
564
|
+
assert (
|
|
565
|
+
_extract_pr_number("https://github.enterprise.test/user/repo/pull/42")
|
|
566
|
+
== "42"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def test_supports_reviewer_comments_accepts_enterprise_url(self) -> None:
|
|
570
|
+
plugin = GitHubWorkspacePlugin()
|
|
571
|
+
|
|
572
|
+
assert (
|
|
573
|
+
plugin.ws_supports_reviewer_comments(
|
|
574
|
+
"https://github.enterprise.test/user/repo/pull/42"
|
|
575
|
+
)
|
|
576
|
+
is False
|
|
577
|
+
)
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# Configuration
|
|
2
|
-
|
|
3
|
-
## `github_orgs`
|
|
4
|
-
|
|
5
|
-
The `github_orgs` setting controls how sase-github clones repositories. Add it to your sase config file
|
|
6
|
-
(`~/.config/sase/sase.yml`):
|
|
7
|
-
|
|
8
|
-
```yaml
|
|
9
|
-
github_orgs:
|
|
10
|
-
- your-username
|
|
11
|
-
- your-org
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
**Effect:** When cloning a repo whose owner is in this list, sase-github uses SSH (`git@github.com:user/project.git`).
|
|
15
|
-
For all other repos, it uses HTTPS (`https://github.com/user/project.git`).
|
|
16
|
-
|
|
17
|
-
This matters because SSH URLs require an SSH key configured with GitHub, while HTTPS URLs work for public repos without
|
|
18
|
-
authentication (but require a token for push access).
|
|
19
|
-
|
|
20
|
-
## Default Config
|
|
21
|
-
|
|
22
|
-
sase-github contributes a `default_config.yml` via the `sase_config` entry point. This is merged into the sase config
|
|
23
|
-
chain between sase core defaults and your user config.
|
|
24
|
-
|
|
25
|
-
Currently the default config defines:
|
|
26
|
-
|
|
27
|
-
- `xprompts.pr_diff` — an xprompt that expands to the diff of the current PR's changes
|
|
28
|
-
|
|
29
|
-
## Requirements
|
|
30
|
-
|
|
31
|
-
- **`gh` CLI** — Required for all PR operations. Install from https://cli.github.com/ and authenticate with
|
|
32
|
-
`gh auth login`.
|
|
33
|
-
- **Git** — Standard git CLI for repository operations.
|
|
34
|
-
|
|
35
|
-
## Workspace Layout
|
|
36
|
-
|
|
37
|
-
Primary GitHub workspaces are stored under `~/projects/github/<user>/<project>/` when first resolved from a
|
|
38
|
-
`#gh(user/project)` reference. Numbered parallel-work checkouts follow SASE's shared `workspace.root` policy: by default
|
|
39
|
-
they live under the platform state-root namespace, while explicit `workspace.root: adjacent` keeps the legacy
|
|
40
|
-
`~/projects/github/<user>/<project>_<N>/` sibling layout.
|
|
41
|
-
|
|
42
|
-
## Project Files
|
|
43
|
-
|
|
44
|
-
Project metadata is stored in `~/.sase/projects/<project>/<project>.sase`; legacy `.gp` files remain readable as a
|
|
45
|
-
fallback. The key field is `WORKSPACE_DIR`, which points to the primary workspace directory and is set automatically
|
|
46
|
-
when you first use an `#gh:<user>/<project>` ref.
|
|
47
|
-
|
|
48
|
-
For new `owner/repo` refs, the project name is based on the full GitHub identity, normally `gh_<user>__<project>`, so
|
|
49
|
-
two owners can have repositories with the same basename. If that canonical name is already occupied by a different
|
|
50
|
-
project, `PROJECT_NAME`, or alias, sase-github adds a deterministic suffix such as `-2`.
|
|
51
|
-
|
|
52
|
-
sase-github also writes `PROJECT_NAME` to the repo basename when it is valid and useful. The first `owner/foo` repo can
|
|
53
|
-
get `PROJECT_NAME: foo`; a second `owner/foo` repo gets the next available display name such as `foo_1`. Existing
|
|
54
|
-
basename ProjectSpecs are reused when their `WORKSPACE_DIR` already matches the GitHub workspace, so no automatic
|
|
55
|
-
migration or rename is required. Existing auto-aliased GitHub projects are also left unchanged and keep resolving via
|
|
56
|
-
their `PROJECT_ALIASES` entry.
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
"""GitHub configuration helpers."""
|
|
2
|
-
|
|
3
|
-
from sase.config import load_merged_config
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def get_github_orgs() -> list[str]:
|
|
7
|
-
"""Read ``github_orgs`` from the merged sase config.
|
|
8
|
-
|
|
9
|
-
Returns:
|
|
10
|
-
A list of GitHub org/user names the user has push access to.
|
|
11
|
-
"""
|
|
12
|
-
config = load_merged_config()
|
|
13
|
-
value = config.get("github_orgs")
|
|
14
|
-
if isinstance(value, list):
|
|
15
|
-
return [str(v) for v in value if v]
|
|
16
|
-
if value:
|
|
17
|
-
return [str(value)]
|
|
18
|
-
return []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|