✨ feat(spx): 按 git origin 支持 GitHub 仓库,state 评论改成隐藏注释格式,新增 issue view / pr view / pr create
This commit is contained in:
1 parent
64d5961b62
commit
c90d84d7f2
17 files changed
+2683
-400
No files matched your search
@@ -1,5 +1,5 @@
|
||||
// Package repo detects the owner/repo pair from a git working directory's
|
||||
// origin remote.
|
||||
// Package repo reads where a repo lives, its forge host and owner/repo pair,
|
||||
// from a git working directory's origin remote.
|
||||
package repo
|
||||
|
||||
import (
|
||||
@@ -16,7 +16,17 @@ import (
|
||||
// url.Parse instead.
|
||||
var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
||||
|
||||
// DetectOwnerRepo runs `git -C cwd remote get-url origin` and parses the URL.
|
||||
// Origin is where a repo lives: the forge host, the forge's web root and the owner/repo pair.
|
||||
type Origin struct {
|
||||
// Host is the lowercase hostname without port, e.g. `github.com`; it decides which forge to talk to.
|
||||
Host string
|
||||
// WebURL is the forge's web root such as `https://gitea.example.com`, the best guess at where its API sits.
|
||||
WebURL string
|
||||
Owner string
|
||||
Repo string
|
||||
}
|
||||
|
||||
// DetectOrigin runs `git -C cwd remote get-url origin` and reads which host and owner/repo the repo lives at.
|
||||
//
|
||||
// Supported formats:
|
||||
// - https://host/owner/repo(.git)
|
||||
@@ -26,37 +36,78 @@ var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
|
||||
//
|
||||
// owner may include subgroups (e.g. "group/subgroup") if the host supports it;
|
||||
// repo is the last path segment without the .git suffix.
|
||||
func DetectOwnerRepo(cwd string) (owner, repo string, err error) {
|
||||
func DetectOrigin(cwd string) (Origin, error) {
|
||||
cmd := exec.Command("git", "-C", cwd, "remote", "get-url", "origin")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
|
||||
return Origin{}, fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
|
||||
}
|
||||
raw := strings.TrimSpace(string(out))
|
||||
if raw == "" {
|
||||
return "", "", fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
|
||||
return Origin{}, fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
|
||||
}
|
||||
return parseRemoteURL(raw)
|
||||
}
|
||||
|
||||
// parseRemoteURL is the pure-logic core of DetectOwnerRepo, extracted for testing.
|
||||
func parseRemoteURL(raw string) (owner, repo string, err error) {
|
||||
// parseRemoteURL is the pure-logic core of [DetectOrigin], extracted for testing.
|
||||
//
|
||||
// An SSH remote says nothing about the web side, so its `WebURL` is a guess of `https://<host>`.
|
||||
func parseRemoteURL(raw string) (Origin, error) {
|
||||
// Try scp-like ssh: `git@host:owner/repo.git` (only if not URI-shaped).
|
||||
if !strings.Contains(raw, "://") {
|
||||
if m := sshLikeRegex.FindStringSubmatch(raw); m != nil {
|
||||
return splitOwnerRepo(m[2])
|
||||
return newOrigin(m[1], "https://"+m[1], m[2])
|
||||
}
|
||||
}
|
||||
// Try URL form (http/https/ssh://).
|
||||
u, perr := url.Parse(raw)
|
||||
if perr != nil {
|
||||
return "", "", fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
||||
return Origin{}, fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return Origin{}, fmt.Errorf("origin URL %q 没有 host 部分", raw)
|
||||
}
|
||||
path := strings.TrimPrefix(u.Path, "/")
|
||||
if path == "" {
|
||||
return "", "", fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
||||
return Origin{}, fmt.Errorf("origin URL %q 没有 path 部分", raw)
|
||||
}
|
||||
return splitOwnerRepo(path)
|
||||
webURL := "https://" + u.Hostname()
|
||||
if u.Scheme == "http" || u.Scheme == "https" {
|
||||
webURL = u.Scheme + "://" + u.Host
|
||||
}
|
||||
return newOrigin(u.Hostname(), webURL, path)
|
||||
}
|
||||
|
||||
// newOrigin assembles an [Origin] from a remote's host, web root and `owner/repo(.git)` path, lowercasing the host.
|
||||
func newOrigin(host, webURL, path string) (Origin, error) {
|
||||
owner, repo, err := splitOwnerRepo(path)
|
||||
if err != nil {
|
||||
return Origin{}, err
|
||||
}
|
||||
return Origin{Host: strings.ToLower(host), WebURL: strings.ToLower(webURL), Owner: owner, Repo: repo}, nil
|
||||
}
|
||||
|
||||
// ParseHost reads a `--host` value, either a bare host like `github.com` or a URL like `https://gitea.example.com/`.
|
||||
//
|
||||
// @return the lowercase hostname without port, used to pick the forge, and the web root to call, which is the URL
|
||||
// itself without its trailing slash, or `https://<host>` for a bare host
|
||||
func ParseHost(value string) (host, webURL string, err error) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return "", "", fmt.Errorf("--host 不能为空")
|
||||
}
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("无法解析 --host %q: %w", value, err)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", "", fmt.Errorf("--host %q 里没有主机名", value)
|
||||
}
|
||||
return strings.ToLower(u.Hostname()), u.Scheme + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/"), nil
|
||||
}
|
||||
|
||||
// splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo").
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
package repo
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRemoteURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw, owner, repo string
|
||||
raw, host, webURL, owner, repo string
|
||||
}{
|
||||
{"https://gitea.example.com/foo/bar.git", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar", "foo", "bar"},
|
||||
{"http://localhost:3000/foo/bar.git", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar.git", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar", "foo", "bar"},
|
||||
{"ssh://git@gitea.example.com:22/foo/bar.git", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"https://gitea.example.com/foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"https://user:secret@gitea.example.com/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"http://localhost:3000/foo/bar.git", "localhost", "http://localhost:3000", "foo", "bar"},
|
||||
{"https://GitHub.com/Foo/Bar.git", "github.com", "https://github.com", "Foo", "Bar"},
|
||||
{"git@gitea.example.com:foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"git@gitea.example.com:foo/bar", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
{"git@github.com:foo/bar.git", "github.com", "https://github.com", "foo", "bar"},
|
||||
{"ssh://git@gitea.example.com:22/foo/bar.git", "gitea.example.com", "https://gitea.example.com", "foo", "bar"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.raw, func(t *testing.T) {
|
||||
owner, repo, err := parseRemoteURL(c.raw)
|
||||
o, err := parseRemoteURL(c.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if owner != c.owner || repo != c.repo {
|
||||
t.Fatalf("got %s/%s want %s/%s", owner, repo, c.owner, c.repo)
|
||||
want := Origin{Host: c.host, WebURL: c.webURL, Owner: c.owner, Repo: c.repo}
|
||||
if o != want {
|
||||
t.Fatalf("got %+v want %+v", o, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -34,8 +41,68 @@ func TestParseRemoteURLRejectsBadInput(t *testing.T) {
|
||||
"https://gitea.example.com/onlyowner",
|
||||
}
|
||||
for _, raw := range bad {
|
||||
if _, _, err := parseRemoteURL(raw); err == nil {
|
||||
if _, err := parseRemoteURL(raw); err == nil {
|
||||
t.Fatalf("expected error for %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, host, webURL string
|
||||
}{
|
||||
{"github.com", "github.com", "https://github.com"},
|
||||
{"GitHub.com", "github.com", "https://github.com"},
|
||||
{"gitea.example.com/", "gitea.example.com", "https://gitea.example.com"},
|
||||
{"https://gitea.example.com/", "gitea.example.com", "https://gitea.example.com"},
|
||||
{"http://localhost:3000", "localhost", "http://localhost:3000"},
|
||||
{"https://example.com/gitea/", "example.com", "https://example.com/gitea"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
host, webURL, err := ParseHost(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if host != c.host || webURL != c.webURL {
|
||||
t.Fatalf("got (%s, %s) want (%s, %s)", host, webURL, c.host, c.webURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, bad := range []string{"", " ", "https://", "https:///foo"} {
|
||||
if _, _, err := ParseHost(bad); err == nil {
|
||||
t.Fatalf("expected error for %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOriginReadsGitRemote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, args := range [][]string{
|
||||
{"init", "-q"},
|
||||
{"remote", "add", "origin", "git@github.com:acme/widgets.git"},
|
||||
} {
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v 失败: %v: %s", args, err, out)
|
||||
}
|
||||
}
|
||||
o, err := DetectOrigin(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("DetectOrigin 出错: %v", err)
|
||||
}
|
||||
want := Origin{Host: "github.com", WebURL: "https://github.com", Owner: "acme", Repo: "widgets"}
|
||||
if o != want {
|
||||
t.Fatalf("got %+v want %+v", o, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectOriginFailsWithoutRemote(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init 失败: %v: %s", err, out)
|
||||
}
|
||||
if _, err := DetectOrigin(dir); err == nil {
|
||||
t.Fatal("没有 origin 时应当报错")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user