From 400c5673843550d6c0c01383812f30ce9d297f7b Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Thu, 9 Jul 2026 21:32:10 +0800 Subject: [PATCH] feat(cmd): add alias/browse/status developer-experience commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 3 个开发者体验命令: - alias 管理命令别名(把长命令变短):alias +list / +set / +delete - browse 在浏览器中打开 GitLink 页面(仓库/Issue/PR/Wiki 等资源) - status 显示当前登录状态和上下文信息(owner/repo/format 等) 提升日常使用与脚本化体验:别名减少重复输入、browse 一键跳转网页、status 快速自检。 含单元测试、帮助文档 doc/commands/alias-browse-status.md。 注册:cmd/root.go 仅新增 3 行 import + 3 行 AddCommand,无其他改动。 来源:GitLink 大赛 2026 子赛题一。 --- cmd/alias/alias.go | 158 ++++++++++++++++++++ cmd/alias/alias_test.go | 224 ++++++++++++++++++++++++++++ cmd/browse/browse.go | 165 ++++++++++++++++++++ cmd/browse/browse_test.go | 176 ++++++++++++++++++++++ cmd/root.go | 6 + cmd/status/status.go | 68 +++++++++ cmd/status/status_test.go | 58 +++++++ doc/commands/alias-browse-status.md | 104 +++++++++++++ 8 files changed, 959 insertions(+) create mode 100644 cmd/alias/alias.go create mode 100644 cmd/alias/alias_test.go create mode 100644 cmd/browse/browse.go create mode 100644 cmd/browse/browse_test.go create mode 100644 cmd/status/status.go create mode 100644 cmd/status/status_test.go create mode 100644 doc/commands/alias-browse-status.md diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go new file mode 100644 index 0000000..a218c79 --- /dev/null +++ b/cmd/alias/alias.go @@ -0,0 +1,158 @@ +package alias + +import ( + "fmt" + "os" + "sort" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// AliasConfig represents the aliases section of the CLI config. +type AliasConfig struct { + Aliases map[string]string `yaml:"aliases,omitempty"` +} + +// NewAliasCmd creates the alias command with subcommands. +func NewAliasCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "alias", + Short: "管理命令别名(把长命令变短)", + Long: `管理 gitlink-cli 的命令别名。 + +别名允许你为常用命令创建简短的名称,例如: + gitlink-cli alias +set rl "repo +list" + 之后可以使用: gitlink-cli rl + +别名存储在 ~/.config/gitlink-cli/aliases.yaml 中。`, + Example: ` gitlink-cli alias +list + gitlink-cli alias +set rl "repo +list" + gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli" + gitlink-cli alias +delete rl`, + } + + cmd.AddCommand( + &cobra.Command{ + Use: "+list", + Short: "列出所有已定义的别名", + Long: "列出所有已定义的命令别名。如果没有任何别名,会给出创建提示。", + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + // Structured formats (json/yaml/table) route through output.Print + // so alias +list integrates with scripts and AI Agents. + if cmdutil.Format == "json" || cmdutil.Format == "yaml" || cmdutil.Format == "table" { + rows := make([]map[string]string, 0, len(aliases)) + names := make([]string, 0, len(aliases)) + for k := range aliases { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + rows = append(rows, map[string]string{"name": k, "command": aliases[k]}) + } + return output.Print(output.SuccessEnvelope(rows, nil), cmdutil.Format) + } + if len(aliases) == 0 { + fmt.Println("(未定义任何别名)") + fmt.Println("使用 alias +set <名称> <命令> 来创建别名") + return nil + } + names := make([]string, 0, len(aliases)) + for k := range aliases { + names = append(names, k) + } + sort.Strings(names) + for _, k := range names { + fmt.Printf(" %-15s → %s\n", k, aliases[k]) + } + return nil + }, + }, + &cobra.Command{ + Use: "+set ", + Short: "设置别名", + Long: "为一条命令设置别名。如果别名已存在,会被覆盖。", + Args: cobra.ExactArgs(2), + Example: ` gitlink-cli alias +set rl "repo +list" + gitlink-cli alias +set ri "repo +info"`, + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + aliases[args[0]] = args[1] + if err := saveAliases(aliases); err != nil { + return err + } + fmt.Printf("别名已设置: %s → %s\n", args[0], args[1]) + return nil + }, + }, + &cobra.Command{ + Use: "+delete ", + Short: "删除别名", + Long: "删除一个已定义的命令别名。", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + if _, ok := aliases[args[0]]; !ok { + return fmt.Errorf("别名 %s 不存在", args[0]) + } + delete(aliases, args[0]) + if err := saveAliases(aliases); err != nil { + return err + } + fmt.Printf("别名已删除: %s\n", args[0]) + return nil + }, + }, + &cobra.Command{ + Use: "+expand ", + Short: "展开别名查看原命令", + Long: "查看一个别名对应的原始命令。如果别名不存在则报错。", + Args: cobra.ExactArgs(1), + Example: ` gitlink-cli alias +expand rl + 输出: rl → repo +list`, + RunE: func(cmd *cobra.Command, args []string) error { + aliases, _ := loadAliases() + expanded, ok := aliases[args[0]] + if !ok { + return fmt.Errorf("别名 %s 不存在", args[0]) + } + fmt.Printf("%s → %s\n", args[0], expanded) + return nil + }, + }, + ) + return cmd +} + +func aliasesPath() string { + return config.ConfigDir() + "/aliases.yaml" +} + +func loadAliases() (map[string]string, error) { + data, err := os.ReadFile(aliasesPath()) + if err != nil { + return make(map[string]string), nil + } + var ac AliasConfig + if err := yaml.Unmarshal(data, &ac); err != nil { + return make(map[string]string), nil + } + if ac.Aliases == nil { + ac.Aliases = make(map[string]string) + } + return ac.Aliases, nil +} + +func saveAliases(a map[string]string) error { + data, err := yaml.Marshal(AliasConfig{Aliases: a}) + if err != nil { + return err + } + os.MkdirAll(config.ConfigDir(), 0700) + return os.WriteFile(aliasesPath(), data, 0600) +} diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go new file mode 100644 index 0000000..ebf2ccd --- /dev/null +++ b/cmd/alias/alias_test.go @@ -0,0 +1,224 @@ +package alias + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" +) + +func TestLoadAliasesEmpty(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + aliases, err := loadAliases() + if err != nil { + t.Fatalf("loadAliases failed: %v", err) + } + if len(aliases) != 0 { + t.Fatalf("expected empty aliases, got %d", len(aliases)) + } +} + +func TestSaveAndLoadAliases(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + original := map[string]string{ + "rl": "repo +list", + "ri": "repo +info", + } + if err := saveAliases(original); err != nil { + t.Fatalf("saveAliases failed: %v", err) + } + + loaded, err := loadAliases() + if err != nil { + t.Fatalf("loadAliases failed: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("expected 2 aliases, got %d", len(loaded)) + } + if loaded["rl"] != "repo +list" { + t.Errorf("expected rl -> repo +list, got %s", loaded["rl"]) + } + if loaded["ri"] != "repo +info" { + t.Errorf("expected ri -> repo +info, got %s", loaded["ri"]) + } +} + +func TestSaveAliasesOverwrite(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + saveAliases(map[string]string{"rl": "repo +list"}) + saveAliases(map[string]string{"rl": "repo +list --owner Gitlink"}) + + loaded, _ := loadAliases() + if loaded["rl"] != "repo +list --owner Gitlink" { + t.Errorf("alias should be overwritten, got %s", loaded["rl"]) + } +} + +func TestLoadAliasesInvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + os.WriteFile(tmpDir+"/aliases.yaml", []byte("{{invalid yaml}}"), 0600) + + aliases, err := loadAliases() + if err != nil { + t.Fatalf("should not error on invalid YAML, got: %v", err) + } + if len(aliases) != 0 { + t.Fatalf("should return empty map on invalid YAML, got %d", len(aliases)) + } +} + +func TestNewAliasCmdStructure(t *testing.T) { + cmd := NewAliasCmd() + if cmd.Use != "alias" { + t.Errorf("expected Use 'alias', got %s", cmd.Use) + } + if !cmd.HasSubCommands() { + t.Error("alias command should have subcommands") + } + + subcmds := cmd.Commands() + if len(subcmds) != 4 { + t.Fatalf("expected 4 subcommands, got %d", len(subcmds)) + } + + expectedUses := map[string]bool{"+list": false, "+set ": false, "+delete ": false, "+expand ": false} + for _, sub := range subcmds { + if _, ok := expectedUses[sub.Use]; ok { + expectedUses[sub.Use] = true + } + } + for use, found := range expectedUses { + if !found { + t.Errorf("subcommand %q not found", use) + } + } +} + +func TestAliasSetAndDeleteFlow(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + // 模拟 +set 操作:直接调用 saveAliases + aliases := make(map[string]string) + aliases["rl"] = "repo +list" + aliases["ri"] = "repo +info" + if err := saveAliases(aliases); err != nil { + t.Fatalf("saveAliases failed: %v", err) + } + + // 验证保存成功 + loaded, _ := loadAliases() + if loaded["rl"] != "repo +list" { + t.Fatalf("alias not saved correctly: %v", loaded) + } + if loaded["ri"] != "repo +info" { + t.Fatalf("alias not saved correctly: %v", loaded) + } + + // 模拟 +delete 操作:删除别名后保存 + delete(loaded, "rl") + if err := saveAliases(loaded); err != nil { + t.Fatalf("saveAliases after delete failed: %v", err) + } + + // 验证删除成功 + final, _ := loadAliases() + if _, ok := final["rl"]; ok { + t.Fatal("alias 'rl' should have been deleted") + } + if final["ri"] != "repo +info" { + t.Fatal("alias 'ri' should still exist") + } +} + +func TestAliasDeleteNonExistent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + // 空别名列表,删除不存在的别名 + aliases, _ := loadAliases() + if _, ok := aliases["nonexistent"]; ok { + t.Fatal("nonexistent alias should not exist") + } + // 验证逻辑:别名不存在时不应执行删除 + // 这对应 alias.go 中 if _, ok := aliases[args[0]]; !ok 的检查 +} + +func TestAliasesFilePath(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + expected := tmpDir + "/aliases.yaml" + got := aliasesPath() + if got != expected { + t.Errorf("expected path %s, got %s", expected, got) + } +} + +func TestAliasExpandExisting(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + saveAliases(map[string]string{ + "rl": "repo +list", + "ri": "repo +info", + }) + + aliases, _ := loadAliases() + if expanded, ok := aliases["rl"]; !ok || expanded != "repo +list" { + t.Fatalf("expected rl → repo +list, got %s", expanded) + } + if expanded, ok := aliases["ri"]; !ok || expanded != "repo +info" { + t.Fatalf("expected ri → repo +info, got %s", expanded) + } +} + +func TestAliasExpandNonExistent(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + + aliases, _ := loadAliases() + if _, ok := aliases["nonexistent"]; ok { + t.Fatal("nonexistent alias should not be found") + } +} + +func TestAliasListJSONFormat(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("GITLINK_CONFIG_DIR", tmpDir) + if err := saveAliases(map[string]string{"rl": "repo +list", "ri": "repo +info"}); err != nil { + t.Fatalf("save: %v", err) + } + cmdutil.Format = "json" + defer func() { cmdutil.Format = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + root := NewAliasCmd() + root.SetArgs([]string{"+list"}) + execErr := root.Execute() + w.Close() + os.Stdout = old + if execErr != nil { + t.Fatalf("execute: %v", execErr) + } + var buf strings.Builder + io.Copy(&buf, r) + out := buf.String() + for _, want := range []string{`"ok": true`, `"name"`, `"rl"`, `"repo +list"`} { + if !strings.Contains(out, want) { + t.Errorf("JSON output missing %q: %s", want, out) + } + } +} diff --git a/cmd/browse/browse.go b/cmd/browse/browse.go new file mode 100644 index 0000000..776bf05 --- /dev/null +++ b/cmd/browse/browse.go @@ -0,0 +1,165 @@ +package browse + +import ( + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +// stdout is the browse command's output target (so tests can redirect). +var stdout io.Writer = os.Stdout + +// browsableKinds maps the first path segment of `browse [/id>` to a URL +// builder. The "default" entry is used as a fallback that appends the raw arg +// to the repo URL, preserving the original passthrough behaviour. +var browsableKinds = []struct { + kind string + desc string +}{ + {"issues", "Issue 列表 / 详情 (issues/42)"}, + {"pulls", "PR 列表 / 详情 (pulls/128)"}, + {"wiki", "Wiki 首页 / 页面 (wiki 或 wiki/API指南)"}, + {"actions", "CI/Actions 页面"}, + {"commits", "提交列表 / 详情 (commits/abc123)"}, + {"branches", "分支列表"}, + {"releases", "Release 列表 / 详情 (releases/v2.0)"}, + {"milestones", "里程碑页面"}, + {"labels", "标签管理页"}, + {"settings/hooks", "Webhook 设置页"}, + {"settings/collaboration", "成员管理页"}, + {"projects", "项目看板页"}, +} + +// NewBrowseCmd creates the browse command for opening GitLink pages in a browser. +func NewBrowseCmd() *cobra.Command { + var listFlag, noOpen bool + cmd := &cobra.Command{ + Use: "browse [resource]", + Short: "在浏览器中打开 GitLink 页面", + Long: `打开当前仓库(或指定资源)的 GitLink 页面。 + +资源格式: issues/42, pulls/42, wiki, wiki/页面名, commits/abc123, ... +不带参数则打开仓库主页。owner/repo 自动从 git remote 推断或用 --owner/--repo 指定。 + +示例: + gitlink-cli browse + gitlink-cli browse issues/42 + gitlink-cli browse pulls/128 + gitlink-cli browse wiki + gitlink-cli browse --list # 列出所有可浏览页面 + gitlink-cli browse --no-open # 只打印 URL,不打开浏览器`, + Example: ` gitlink-cli browse + gitlink-cli browse issues/42 + gitlink-cli browse pulls/128 + gitlink-cli browse wiki + gitlink-cli browse --list + gitlink-cli browse --no-open`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) + if err != nil { + return fmt.Errorf("无法推断仓库信息: %w", err) + } + + if listFlag { + listBrowsables(owner, repo) + return nil + } + + rurl := resolveBrowseURL(web.NewBuilder(), owner, repo, args) + emitBrowse(rurl) + if !noOpen { + if err := web.OpenBrowser(rurl.URL); err != nil { + // 打开失败仅告警,URL 已经打印供手动复制 + fmt.Fprintf(stdout, "(浏览器未自动打开: %v;请手动复制上方 URL)\n", err) + } + } + return nil + }, + } + cmd.Flags().BoolVar(&listFlag, "list", false, "列出当前仓库所有可浏览的页面") + cmd.Flags().BoolVar(&noOpen, "no-open", false, "只打印 URL,不打开浏览器") + return cmd +} + +// emitBrowse prints the URL — friendly single line by default, structured +// envelope when --format is set. +func emitBrowse(r *web.ResourceURL) { + if cmdutil.Format == "" { + fmt.Fprintf(stdout, "🔗 %s\n", r.URL) + return + } + _ = output.PrintTo(stdout, output.SuccessEnvelope(r, nil), cmdutil.Format) +} + +// listBrowsables prints the catalog of pages `browse` understands. +func listBrowsables(owner, repo string) { + fmt.Fprintf(stdout, "可浏览的 GitLink 页面 (%s/%s):\n", owner, repo) + for _, k := range browsableKinds { + fmt.Fprintf(stdout, " %-28s %s\n", k.kind, k.desc) + } + fmt.Fprintf(stdout, "\n用法: gitlink-cli browse <资源>\n") +} + +// resolveBrowseURL maps `browse ` to a web URL. With no arg → repo home. +func resolveBrowseURL(b *web.Builder, owner, repo string, args []string) *web.ResourceURL { + if len(args) == 0 || args[0] == "" { + return b.RepoURL(owner, repo) + } + arg := strings.TrimPrefix(args[0], "/") + // Split into kind and (optional) rest after the first "/". + kind, rest, _ := strings.Cut(arg, "/") + rest = strings.Trim(rest, "/") + + switch { + case kind == "issues" || kind == "issue": + return b.IssueURL(owner, repo, atoiOrZero(rest)) + case kind == "pulls" || kind == "pr" || kind == "pull": + return b.PRURL(owner, repo, atoiOrZero(rest)) + case kind == "wiki": + return b.WikiURL(owner, repo, rest) + case kind == "actions" || kind == "ci": + return b.CIURL(owner, repo) + case kind == "commits": + return b.CommitURL(owner, repo, rest) + case kind == "branches": + return b.BranchURL(owner, repo, rest) + case kind == "releases": + return b.ReleaseURL(owner, repo, rest) + case kind == "milestones": + return b.MilestoneURL(owner, repo) + case kind == "labels": + return b.LabelURL(owner, repo) + case arg == "settings/hooks": + return b.WebhookURL(owner, repo) + case arg == "settings/collaboration": + return b.MemberURL(owner, repo) + case kind == "settings": + return b.RepoURL(owner, repo) // settings landing falls back to repo home + default: + // Unknown resource: append the raw arg as a path segment so behaviour + // stays predictable for callers that already know their URL shape. + return &web.ResourceURL{ + URL: b.RepoURL(owner, repo).URL + "/" + arg, + Resource: "custom", + Identifier: arg, + } + } +} + +func atoiOrZero(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} diff --git a/cmd/browse/browse_test.go b/cmd/browse/browse_test.go new file mode 100644 index 0000000..e2b1715 --- /dev/null +++ b/cmd/browse/browse_test.go @@ -0,0 +1,176 @@ +package browse + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/web" +) + +func TestNewBrowseCmd(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Use != "browse [resource]" { + t.Errorf("expected Use 'browse [resource]', got %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + if cmd.Long == "" { + t.Error("Long description should not be empty") + } +} + +func TestBrowseCmdHasCorrectArgs(t *testing.T) { + cmd := NewBrowseCmd() + if err := cmd.Args(cmd, []string{}); err != nil { + t.Errorf("should accept 0 args: %v", err) + } + if err := cmd.Args(cmd, []string{"issues/42"}); err != nil { + t.Errorf("should accept 1 arg: %v", err) + } + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { + t.Error("should reject more than 1 arg") + } +} + +func TestBrowseCmdNoSubcommands(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.HasSubCommands() { + t.Error("browse should not have subcommands") + } +} + +func TestBrowseCmdExample(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Example == "" { + t.Error("Example should not be empty") + } + if !strings.Contains(cmd.Example, "browse") { + t.Error("Example should contain 'browse'") + } +} + +func TestBrowseCmdHasListAndNoOpenFlags(t *testing.T) { + cmd := NewBrowseCmd() + if cmd.Flags().Lookup("list") == nil { + t.Error("missing --list flag") + } + if cmd.Flags().Lookup("no-open") == nil { + t.Error("missing --no-open flag") + } +} + +func TestResolveBrowseURL(t *testing.T) { + b := web.NewBuilder() + cases := []struct { + name string + args []string + wantSub string + }{ + {"no args → repo", nil, "/o/r"}, + {"issue detail", []string{"issues/42"}, "/issues/42"}, + {"issue alias", []string{"issue/7"}, "/issues/7"}, + {"pr detail", []string{"pulls/128"}, "/pulls/128"}, + {"pr alias", []string{"pr/9"}, "/pulls/9"}, + {"wiki index", []string{"wiki"}, "/wiki"}, + {"wiki page", []string{"wiki/Guide"}, "/wiki/Guide"}, + {"ci", []string{"actions"}, "/actions"}, + {"ci alias", []string{"ci"}, "/actions"}, + {"commit", []string{"commits/abc123"}, "/commits/abc123"}, + {"release", []string{"releases/v2.0"}, "/releases/v2.0"}, + {"milestones", []string{"milestones"}, "/milestones"}, + {"labels", []string{"labels"}, "/issues/labels"}, + {"webhook settings", []string{"settings/hooks"}, "/settings/hooks"}, + {"collaboration", []string{"settings/collaboration"}, "/settings/collaboration"}, + {"unknown passthrough", []string{"custom/seg"}, "/custom/seg"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := resolveBrowseURL(b, "o", "r", c.args) + if !strings.Contains(r.URL, c.wantSub) { + t.Errorf("URL %q missing %q", r.URL, c.wantSub) + } + }) + } +} + +func TestBrowseListOutputsCatalog(t *testing.T) { + out := runBrowse(t, "--owner", "o", "--repo", "r", "--list") + for _, want := range []string{"issues", "pulls", "wiki", "actions"} { + if !strings.Contains(out, want) { + t.Errorf("list missing %q: %q", want, out) + } + } +} + +func TestBrowseJSONFormat(t *testing.T) { + // --format json must route emitBrowse through the output envelope. + out := runBrowseFmt(t, "json", "issues/42") + if !strings.Contains(out, `"html_url"`) { + t.Errorf("JSON browse missing html_url: %q", out) + } +} + +func TestResolveBrowseURLIssueNonNumeric(t *testing.T) { + // atoiOrZero("abc") should fall back to 0 (issue list page). + r := resolveBrowseURL(web.NewBuilder(), "o", "r", []string{"issues/abc"}) + if !strings.HasSuffix(r.URL, "/issues") { + t.Errorf("expected /issues fallback, got %q", r.URL) + } +} + +func TestBrowseNoOpenDoesNotLaunchBrowser(t *testing.T) { + // --no-open must print the URL but never invoke a browser. We can't easily + // stub web.OpenBrowser across packages, so we assert the URL is printed + // and that the "browser did not open" warning (printed only when + // OpenBrowser returns an error) is absent. + out := runBrowseNoOpen(t, "issues/42", true) + if !strings.Contains(out, "/issues/42") { + t.Errorf("expected /issues/42 in output: %q", out) + } + if strings.Contains(out, "浏览器未自动打开") { + t.Errorf("--no-open should not print open-failure warning: %q", out) + } +} + +// runBrowse runs `browse ` with captured stdout. +func runBrowse(t *testing.T, args ...string) string { + t.Helper() + old := stdout + oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format + buf := &bytes.Buffer{} + stdout = buf + defer func() { + stdout = old + cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt + }() + cmd := NewBrowseCmd() + cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "") + cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "") + cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "") + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("browse %v: %v", args, err) + } + return buf.String() +} + +// runBrowseFmt runs `browse ` with a specific --format value. +func runBrowseFmt(t *testing.T, format, resource string) string { + t.Helper() + return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource) +} + +func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string { + t.Helper() + args := []string{"--owner", "o", "--repo", "r"} + if resource != "" { + args = append(args, resource) + } + if noOpen { + args = append(args, "--no-open") + } + return runBrowse(t, args...) +} diff --git a/cmd/root.go b/cmd/root.go index 75f8532..cdf49b8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,11 +7,14 @@ import ( "github.com/spf13/cobra" + aliasCmd "github.com/gitlink-org/gitlink-cli/cmd/alias" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" + browseCmd "github.com/gitlink-org/gitlink-cli/cmd/browse" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor" + statusCmd "github.com/gitlink-org/gitlink-cli/cmd/status" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" @@ -59,6 +62,9 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr)) rootCmd.AddCommand(newVersionCmd(version, tr)) + rootCmd.AddCommand(aliasCmd.NewAliasCmd()) + rootCmd.AddCommand(browseCmd.NewBrowseCmd()) + rootCmd.AddCommand(statusCmd.NewStatusCmd()) shortcuts.RegisterAll(rootCmd, tr) diff --git a/cmd/status/status.go b/cmd/status/status.go new file mode 100644 index 0000000..be4b8b2 --- /dev/null +++ b/cmd/status/status.go @@ -0,0 +1,68 @@ +package status + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/context" +) + +// NewStatusCmd creates the status command that displays login state and context. +func NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "显示当前登录状态和上下文信息", + Long: `显示 gitlink-cli 的当前状态,包括: + - 认证状态(是否已登录、Token 来源) + - API 地址 + - 当前目录 + - 自动推断的仓库信息`, + Example: ` gitlink-cli status`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, _ := config.Load() + token, _ := auth.LoadToken() + if token == "" { + token = os.Getenv("GITLINK_TOKEN") + } + cwd, _ := os.Getwd() + + fmt.Println("GitLink CLI 状态") + fmt.Println("───────────────") + + // 认证状态 + if token != "" { + fmt.Println(" 认证状态: 已登录") + fmt.Printf(" Token 来源: %s\n", tokenSource(token)) + } else { + fmt.Println(" 认证状态: 未登录(运行 gitlink-cli auth login)") + } + + // API 地址 + fmt.Printf(" API 地址: %s\n", cfg.BaseURL) + + // 当前目录 + fmt.Printf(" 当前目录: %s\n", cwd) + + // 推断的仓库 + owner, repo, err := context.ResolveOwnerRepo("", "") + if err == nil { + fmt.Printf(" 推断仓库: %s/%s\n", owner, repo) + } else { + fmt.Println(" 推断仓库: (不在 Git 仓库中)") + } + + return nil + }, + } +} + +func tokenSource(token string) string { + if token == os.Getenv("GITLINK_TOKEN") { + return "环境变量 GITLINK_TOKEN" + } + return "keyring / 配置文件" +} diff --git a/cmd/status/status_test.go b/cmd/status/status_test.go new file mode 100644 index 0000000..b88a176 --- /dev/null +++ b/cmd/status/status_test.go @@ -0,0 +1,58 @@ +package status + +import ( + "strings" + "testing" +) + +func TestNewStatusCmd(t *testing.T) { + cmd := NewStatusCmd() + if cmd.Use != "status" { + t.Errorf("expected Use 'status', got %s", cmd.Use) + } + if cmd.Short == "" { + t.Error("Short description should not be empty") + } + if cmd.Long == "" { + t.Error("Long description should not be empty") + } +} + +func TestNewStatusCmdExample(t *testing.T) { + cmd := NewStatusCmd() + if !strings.Contains(cmd.Example, "status") { + t.Errorf("Example should contain 'status', got: %s", cmd.Example) + } +} + +func TestNewStatusCmdHasNoSubcommands(t *testing.T) { + cmd := NewStatusCmd() + if cmd.HasSubCommands() { + t.Error("status should not have subcommands") + } +} + +func TestTokenSourceFromEnv(t *testing.T) { + t.Setenv("GITLINK_TOKEN", "test-token-123") + result := tokenSource("test-token-123") + if result != "环境变量 GITLINK_TOKEN" { + t.Errorf("expected env source, got: %s", result) + } +} + +func TestTokenSourceFromKeyring(t *testing.T) { + // 不设置环境变量,或用不同的值 + t.Setenv("GITLINK_TOKEN", "") + result := tokenSource("some-stored-token") + if result != "keyring / 配置文件" { + t.Errorf("expected keyring source, got: %s", result) + } +} + +func TestTokenSourceMismatch(t *testing.T) { + t.Setenv("GITLINK_TOKEN", "env-token") + result := tokenSource("different-token") + if result != "keyring / 配置文件" { + t.Errorf("should fallback to keyring when token differs from env, got: %s", result) + } +} diff --git a/doc/commands/alias-browse-status.md b/doc/commands/alias-browse-status.md new file mode 100644 index 0000000..f9caea6 --- /dev/null +++ b/doc/commands/alias-browse-status.md @@ -0,0 +1,104 @@ +# alias — 命令别名管理 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +alias 命令用于管理 gitlink-cli 的命令别名,将常用长命令缩短为简短别名,提升日常使用效率。对标 `gh alias`。 + +## 命令列表 + +### alias +list +- **用途**: 列出所有已定义的命令别名 +- **示例**: `gitlink-cli alias +list` + +### alias +set \ \ +- **用途**: 设置命令别名 +- **参数**: name(别名名称)和 command(实际命令) +- **示例**: + - `gitlink-cli alias +set rl "repo +list"` + - `gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli"` + +### alias +delete \ +- **用途**: 删除已定义的命令别名 +- **参数**: name(要删除的别名名称) +- **示例**: `gitlink-cli alias +delete rl` + +## 存储 + +别名存储在 `~/.config/gitlink-cli/aliases.yaml` 文件中,格式为 YAML。 + +--- + +# browse — 浏览器打开 GitLink 页面 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +browse 命令用于在浏览器中快速打开当前仓库或指定资源的 GitLink 页面。对标 `gh browse`。 + +## 用法 + +``` +gitlink-cli browse [resource] +``` + +- 不带参数:打开当前仓库主页 +- 带参数:打开指定资源页面 + +## 示例 + +```bash +# 打开当前仓库主页 +gitlink-cli browse + +# 打开指定 Issue +gitlink-cli browse issues/42 + +# 打开指定 PR +gitlink-cli browse pulls/128 + +# 打开 Wiki 页面 +gitlink-cli browse wiki +``` + +## 跨平台支持 + +- macOS: 使用 `open` 命令 +- Windows: 使用 `start` 命令 +- Linux: 使用 `xdg-open` 命令 + +--- + +# status — 显示当前状态 + +> 关联 Issue: #14 | PR: #11 + +## 概述 + +status 命令显示 gitlink-cli 的当前状态信息,包括认证状态、API 地址、当前目录和自动推断的仓库信息。对标 `gh auth status`。 + +## 用法 + +```bash +gitlink-cli status +``` + +## 输出示例 + +``` +GitLink CLI 状态 +─────────────── + 认证状态: 已登录 + Token 来源: keyring / 配置文件 + API 地址: https://www.gitlink.org.cn/api + 当前目录: /home/user/projects/gitlink-cli + 推断仓库: Gitlink/gitlink-cli +``` + +## 功能说明 + +- 自动检测 Token 来源(环境变量 GITLINK_TOKEN / keyring / 配置文件) +- 自动从 git remote 推断 owner/repo +- 未登录时给出 `gitlink-cli auth login` 提示