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.
Files changed (39) hide show
  1. sase_github-0.1.6/.release-please-manifest.json +3 -0
  2. {sase_github-0.1.4 → sase_github-0.1.6}/CHANGELOG.md +24 -0
  3. {sase_github-0.1.4 → sase_github-0.1.6}/PKG-INFO +1 -1
  4. {sase_github-0.1.4 → sase_github-0.1.6}/README.md +40 -12
  5. {sase_github-0.1.4 → sase_github-0.1.6}/docs/architecture.md +7 -5
  6. sase_github-0.1.6/docs/configuration.md +114 -0
  7. {sase_github-0.1.4 → sase_github-0.1.6}/pyproject.toml +1 -1
  8. sase_github-0.1.6/src/sase_github/config.py +88 -0
  9. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/plugin.py +5 -5
  10. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/workspace_plugin.py +42 -15
  11. sase_github-0.1.6/tests/test_config.py +50 -0
  12. {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_github_plugin.py +63 -12
  13. {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_submit_with_recorded_pr.py +6 -0
  14. {sase_github-0.1.4 → sase_github-0.1.6}/tests/test_workspace_plugin.py +215 -0
  15. sase_github-0.1.4/.release-please-manifest.json +0 -3
  16. sase_github-0.1.4/docs/configuration.md +0 -56
  17. sase_github-0.1.4/src/sase_github/config.py +0 -18
  18. {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/ci.yml +0 -0
  19. {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/pr-title.yml +0 -0
  20. {sase_github-0.1.4 → sase_github-0.1.6}/.github/workflows/publish.yml +0 -0
  21. {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/beads.db +0 -0
  22. {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/config.json +0 -0
  23. {sase_github-0.1.4 → sase_github-0.1.6}/.sase_beads/issues.jsonl +0 -0
  24. {sase_github-0.1.4 → sase_github-0.1.6}/CLAUDE.md +0 -0
  25. {sase_github-0.1.4 → sase_github-0.1.6}/Justfile +0 -0
  26. {sase_github-0.1.4 → sase_github-0.1.6}/LICENSE +0 -0
  27. {sase_github-0.1.4 → sase_github-0.1.6}/docs/xprompts.md +0 -0
  28. {sase_github-0.1.4 → sase_github-0.1.6}/release-please-config.json +0 -0
  29. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/__init__.py +0 -0
  30. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/default_config.yml +0 -0
  31. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/__init__.py +0 -0
  32. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/gh_setup.py +0 -0
  33. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/scripts/new_pr_desc_get_context.py +0 -0
  34. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/gh.yml +0 -0
  35. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/new_pr_desc.yml +0 -0
  36. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/pr_diff.yml +0 -0
  37. {sase_github-0.1.4 → sase_github-0.1.6}/src/sase_github/xprompts/prdd.yml +0 -0
  38. {sase_github-0.1.4 → sase_github-0.1.6}/tests/__init__.py +0 -0
  39. {sase_github-0.1.4 → sase_github-0.1.6}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.1.6"
3
+ }
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sase-github
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: GitHub VCS plugin for sase
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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 GitHub-hosted
12
- repositories, integrating with the `gh` CLI for pull request creation, management, and submission, along with
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
- pip install sase-github
33
+ uv tool install sase --with sase-github
19
34
  ```
20
35
 
21
- Or with [uv](https://docs.astral.sh/uv/):
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
- uv pip install sase-github
42
+ sase plugin install github
25
43
  ```
26
44
 
27
- Requires `sase>=0.1.3` as a dependency (installed automatically).
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 GitHub-hosted repository (via `gh` CLI), it automatically loads `GitHubPlugin` and
65
- `GitHubWorkspacePlugin` to handle VCS operations like PR creation, branch management, commit workflows, and PR
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 list)
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 with `github.com` in their origin URL |
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
- - Clones the repo if it doesn't exist (SSH for orgs in `github_orgs`, HTTPS otherwise)
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 `get_github_orgs()` which reads the `github_orgs` list from the merged sase config. This determines
108
- whether repos are cloned via SSH (for orgs the user has push access to) or HTTPS.
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sase-github"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "GitHub VCS plugin for sase"
9
9
  requires-python = ">=3.12"
10
10
  license = "MIT"
@@ -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 with ``github.com`` in their origin URL."""
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
- if "github.com" in url:
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 = re.match(r"https?://github\.com/.+/pull/(\d+)", cl_url)
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
- f'[ "$state" = "MERGED" ]'
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 re.match(r"https?://github\.com/", cl_url):
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(user: str, project: str, target_dir: str) -> None:
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
- url = f"git@github.com:{user}/{project}.git"
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://github.com/{user}/{project}.git"
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
- return str(Path.home() / "projects" / "github" / user / project) + "/"
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
- primary_workspace_dir = _github_workspace_dir(user, project)
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 = re.match(r"https?://github\.com/.+/pull/(\d+)", cl_url)
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,3 +0,0 @@
1
- {
2
- ".": "0.1.4"
3
- }
@@ -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