Merge PR #392: feat(cmd): 新增 alias/browse/status 开发者体验命令
# Conflicts: # cmd/alias/alias.go # cmd/alias/alias_test.go
This commit is contained in:
commit
3e8e634a61
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 / 配置文件"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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` 提示
|
||||
Loading…
Reference in New Issue