Merge PR #392: feat(cmd): 新增 alias/browse/status 开发者体验命令

# Conflicts:
#	cmd/alias/alias.go
#	cmd/alias/alias_test.go
This commit is contained in:
wbtiger 2026-07-14 22:49:56 +08:00
commit 3e8e634a61
8 changed files with 898 additions and 295 deletions

View File

@ -1,178 +1,158 @@
package alias
import (
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
func NewAliasCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
// 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: tr.T("cmd.alias.short"),
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(newSetCmd(tr))
cmd.AddCommand(newListCmd(tr))
cmd.AddCommand(newDeleteCmd(tr))
cmd.AddCommand(newImportCmd(tr))
return cmd
}
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
tr := resolve(translators)
cmd := &cobra.Command{
Use: "set <name> <expansion>...",
Short: tr.T("cmd.alias.set.short"),
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
// Everything after the name forms the expansion, so quoting on the
// shell is optional for multi-word expansions.
expansion := strings.Join(args[1:], " ")
if strings.TrimSpace(expansion) == "" {
return errors.New(tr.T("error.alias.empty_expansion"))
}
cfg, err := internalConfig.Load()
if err != nil {
return err
}
if cfg.Aliases == nil {
cfg.Aliases = map[string]string{}
}
cfg.Aliases[name] = expansion
if err := internalConfig.Save(cfg); err != nil {
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.set", i18n.Args{
"name": name,
"expansion": expansion,
}))
return err
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
},
},
}
// Once the alias name is read, treat the rest of the line as the literal
// expansion so flag-looking tokens (e.g. --label) are not parsed here.
cmd.Flags().SetInterspersed(false)
return cmd
}
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
tr := resolve(translators)
return &cobra.Command{
Use: "list",
Short: tr.T("cmd.alias.list.short"),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := internalConfig.Load()
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(cfg.Aliases) == 0 {
_, err := fmt.Fprintln(out, tr.T("output.alias.none"))
return err
}
names := make([]string, 0, len(cfg.Aliases))
for name := range cfg.Aliases {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if _, err := fmt.Fprintf(out, "%s: %s\n", name, cfg.Aliases[name]); err != nil {
&cobra.Command{
Use: "+set <name> <command>",
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
}
}
return nil
fmt.Printf("别名已设置: %s → %s\n", args[0], args[1])
return nil
},
},
}
}
func newDeleteCmd(translators ...*i18n.Translator) *cobra.Command {
tr := resolve(translators)
return &cobra.Command{
Use: "delete <name>",
Short: tr.T("cmd.alias.delete.short"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
cfg, err := internalConfig.Load()
if err != nil {
return err
}
if _, ok := cfg.Aliases[name]; !ok {
return errors.New(tr.Tf("error.alias.not_found", i18n.Args{"name": name}))
}
delete(cfg.Aliases, name)
if err := internalConfig.Save(cfg); err != nil {
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.deleted", i18n.Args{"name": name}))
return err
&cobra.Command{
Use: "+delete <name>",
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
},
},
}
}
func newImportCmd(translators ...*i18n.Translator) *cobra.Command {
tr := resolve(translators)
var file string
cmd := &cobra.Command{
Use: "import",
Short: tr.T("cmd.alias.import.short"),
RunE: func(cmd *cobra.Command, args []string) error {
data, err := readImportSource(cmd.InOrStdin(), file)
if err != nil {
return errors.New(tr.Tf("error.alias.import_read", i18n.Args{"message": err.Error()}))
}
var pairs map[string]string
if err := yaml.Unmarshal(data, &pairs); err != nil {
return errors.New(tr.Tf("error.alias.import_parse", i18n.Args{"message": err.Error()}))
}
cfg, err := internalConfig.Load()
if err != nil {
return err
}
if cfg.Aliases == nil {
cfg.Aliases = map[string]string{}
}
for name, expansion := range pairs {
cfg.Aliases[name] = expansion
}
if err := internalConfig.Save(cfg); err != nil {
return errors.New(tr.Tf("error.alias.save_failed", i18n.Args{"message": err.Error()}))
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.alias.imported", i18n.Args{"count": len(pairs)}))
return err
&cobra.Command{
Use: "+expand <name>",
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
},
},
}
cmd.Flags().StringVar(&file, "file", "", tr.T("flag.alias.file"))
)
return cmd
}
func readImportSource(stdin io.Reader, file string) ([]byte, error) {
if file != "" {
return os.ReadFile(file)
}
return io.ReadAll(stdin)
func aliasesPath() string {
return config.ConfigDir() + "/aliases.yaml"
}
func resolve(translators []*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
func loadAliases() (map[string]string, error) {
data, err := os.ReadFile(aliasesPath())
if err != nil {
return make(map[string]string), nil
}
return i18n.Default()
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)
}

View File

@ -1,177 +1,224 @@
package alias
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
)
func tempConfigDir(t *testing.T) {
t.Helper()
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
}
func TestLoadAliasesEmpty(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
func run(t *testing.T, cmd *cobra.Command, args ...string) string {
t.Helper()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute %v: %v", args, err)
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))
}
return out.String()
}
func TestNewAliasCmd(t *testing.T) {
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.Fatalf("Use = %q, want alias", cmd.Use)
t.Errorf("expected Use 'alias', got %s", cmd.Use)
}
expected := map[string]bool{
"set <name> <expansion>...": false,
"list": false,
"delete <name>": false,
"import": false,
if !cmd.HasSubCommands() {
t.Error("alias command should have subcommands")
}
for _, sub := range cmd.Commands() {
if _, ok := expected[sub.Use]; !ok {
t.Fatalf("unexpected subcommand: %q", sub.Use)
}
expected[sub.Use] = true
if sub.Short == "" {
t.Fatalf("subcommand %q has empty Short", sub.Use)
subcmds := cmd.Commands()
if len(subcmds) != 4 {
t.Fatalf("expected 4 subcommands, got %d", len(subcmds))
}
expectedUses := map[string]bool{"+list": false, "+set <name> <command>": false, "+delete <name>": false, "+expand <name>": false}
for _, sub := range subcmds {
if _, ok := expectedUses[sub.Use]; ok {
expectedUses[sub.Use] = true
}
}
for name, found := range expected {
for use, found := range expectedUses {
if !found {
t.Fatalf("missing subcommand: %q", name)
t.Errorf("subcommand %q not found", use)
}
}
}
func TestAliasSetListDelete(t *testing.T) {
tempConfigDir(t)
func TestAliasSetAndDeleteFlow(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("GITLINK_CONFIG_DIR", tmpDir)
sets := []struct {
args []string
want string
}{
{[]string{"co", "pr", "+view"}, "pr +view"},
{[]string{"bugs", "issue", "+list", "--label", "bug"}, "issue +list --label bug"},
}
for _, s := range sets {
run(t, newSetCmd(), s.args...)
// 模拟 +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)
}
// set persists the joined expansion
cfg, err := internalConfig.Load()
if err != nil {
t.Fatalf("Load error: %v", err)
// 验证保存成功
loaded, _ := loadAliases()
if loaded["rl"] != "repo +list" {
t.Fatalf("alias not saved correctly: %v", loaded)
}
for _, s := range sets {
if cfg.Aliases[s.args[0]] != s.want {
t.Fatalf("Aliases[%q] = %q, want %q", s.args[0], cfg.Aliases[s.args[0]], s.want)
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)
}
}
// list shows both
listOut := run(t, newListCmd())
for _, s := range sets {
if !strings.Contains(listOut, s.args[0]+": "+s.want) {
t.Fatalf("list missing %q, got:\n%s", s.args[0], listOut)
}
}
// delete removes one, keeps the other
run(t, newDeleteCmd(), "co")
listOut = run(t, newListCmd())
if strings.Contains(listOut, "co: ") {
t.Fatalf("expected co deleted, got:\n%s", listOut)
}
if !strings.Contains(listOut, "bugs: ") {
t.Fatalf("expected bugs retained, got:\n%s", listOut)
}
// deleting a missing alias errors
del := newDeleteCmd()
del.SetOut(&bytes.Buffer{})
del.SetErr(&bytes.Buffer{})
del.SetArgs([]string{"nope"})
if err := del.Execute(); err == nil {
t.Fatal("expected error deleting missing alias")
}
}
func TestAliasSetRejectsEmptyExpansion(t *testing.T) {
tempConfigDir(t)
cmd := newSetCmd()
// MinimumNArgs(2) is satisfied, but a whitespace-only expansion is rejected.
if err := cmd.RunE(cmd, []string{"x", " "}); err == nil {
t.Fatal("expected error for empty expansion")
}
}
func TestAliasListEmpty(t *testing.T) {
tempConfigDir(t)
out := run(t, newListCmd())
if strings.TrimSpace(out) == "" {
t.Fatal("expected a message for empty alias list")
}
}
func TestAliasImportFile(t *testing.T) {
tempConfigDir(t)
dir := t.TempDir()
path := filepath.Join(dir, "aliases.yaml")
content := "co: pr +view\nbugs: issue +list --label bug\n"
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write error: %v", err)
}
cmd := newImportCmd()
run(t, cmd, "--file", path)
cfg, err := internalConfig.Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.Aliases["co"] != "pr +view" || cfg.Aliases["bugs"] != "issue +list --label bug" {
t.Fatalf("imported aliases = %#v", cfg.Aliases)
}
}
func TestAliasImportStdin(t *testing.T) {
tempConfigDir(t)
cmd := newImportCmd()
cmd.SetIn(strings.NewReader("ci: ci +status\n"))
run(t, cmd)
cfg, err := internalConfig.Load()
if err != nil {
t.Fatalf("Load error: %v", err)
}
if cfg.Aliases["ci"] != "ci +status" {
t.Fatalf("imported alias = %q", cfg.Aliases["ci"])
}
}
func TestAliasImportInvalidYAML(t *testing.T) {
tempConfigDir(t)
cmd := newImportCmd()
cmd.SetIn(strings.NewReader("::: not yaml :::"))
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(nil)
if err := cmd.Execute(); err == nil {
t.Fatal("expected error for invalid YAML")
}
}

165
cmd/browse/browse.go Normal file
View File

@ -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 <kind>[/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 <arg>` 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
}

176
cmd/browse/browse_test.go Normal file
View File

@ -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 <args>` 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 <resource>` 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...)
}

View File

@ -10,9 +10,11 @@ import (
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/internal/output"
@ -66,6 +68,9 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
rootCmd.AddCommand(doctorCmd.NewDoctorCmd(tr))
rootCmd.AddCommand(newCompletionCmd(tr))
rootCmd.AddCommand(newVersionCmd(version, tr))
rootCmd.AddCommand(aliasCmd.NewAliasCmd())
rootCmd.AddCommand(browseCmd.NewBrowseCmd())
rootCmd.AddCommand(statusCmd.NewStatusCmd())
shortcuts.RegisterAll(rootCmd, tr)

68
cmd/status/status.go Normal file
View File

@ -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 / 配置文件"
}

58
cmd/status/status_test.go Normal file
View File

@ -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)
}
}

View File

@ -0,0 +1,104 @@
# alias — 命令别名管理
> 关联 Issue: #14 | PR: #11
## 概述
alias 命令用于管理 gitlink-cli 的命令别名,将常用长命令缩短为简短别名,提升日常使用效率。对标 `gh alias`
## 命令列表
### alias +list
- **用途**: 列出所有已定义的命令别名
- **示例**: `gitlink-cli alias +list`
### alias +set \<name\> \<command\>
- **用途**: 设置命令别名
- **参数**: name别名名称和 command实际命令
- **示例**:
- `gitlink-cli alias +set rl "repo +list"`
- `gitlink-cli alias +set ri "repo +info --owner Gitlink --repo gitlink-cli"`
### alias +delete \<name\>
- **用途**: 删除已定义的命令别名
- **参数**: 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` 提示