diff --git a/.devops/ci.yml b/.devops/ci.yml index cea4f8e4..f8a9834d 100644 --- a/.devops/ci.yml +++ b/.devops/ci.yml @@ -31,7 +31,7 @@ workflow: ssh_user: '"root"' ssh_pass: ((gitlink_cli.wyx_ssh_pass)) ssh_cmd: >- - "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && go version && echo '>>> 1. 编译检查' && go build ./... && echo '>>> 2. 静态分析' && go vet ./... && echo '>>> 3. 单元测试' && go test -race ./... && echo '>>> 4. 格式化修复' && gofmt -s -w . && echo '✅ 所有 CI 检查通过'" + "cd /root && rm -rf gitlink-cli && git clone --depth=1 -b master https://gitlink.org.cn/jiangtx/gitlink-cli.git && cd gitlink-cli && export PATH=$PATH:/usr/local/go/bin && export GOPROXY=https://goproxy.cn,direct && go version && echo '>>> 1. 编译检查' && go build ./... && echo '>>> 2. 静态分析' && go vet ./... && echo '>>> 3. 单元测试 + 覆盖率门禁' && go test -race -coverprofile=coverage.out ./... && go tool cover -func=coverage.out | grep '^total:' && COV=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print int($3)}') && echo '覆盖率: '$COV'% (门禁阈值 78%)' && [ $COV -ge 78 ] && echo '✅ 覆盖率达标' && echo '>>> 4. 格式化修复' && gofmt -s -w . && echo '✅ 所有 CI 检查通过'" needs: - git_clone_0 - ref: end diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a835c178..2bbdb156 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: - name: Lint run: make lint - - name: Test - run: make test + - name: Test with coverage gate + run: make cover - name: Check formatting run: make fmt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d5673cc..5957dc6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,5 +26,14 @@ jobs: - name: Scan i18n key references run: go run ./internal/i18n/cmd/check --scan-code - - name: Run Go tests - run: go test ./... + - name: Run Go tests with coverage gate + run: make cover + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ github.sha }} + path: | + coverage.out + coverage.html + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 8c6702d9..46b82e2c 100644 --- a/Makefile +++ b/Makefile @@ -28,9 +28,17 @@ fmt: exit 1; \ fi +COVER_THRESHOLD ?= 78 + cover: go test -coverprofile=coverage.out ./... - go tool cover -func=coverage.out + @go tool cover -func=coverage.out | grep '^total:' + @COV=$$(go tool cover -func=coverage.out | grep '^total:' | awk '{print int($$3)}'); \ + echo "总覆盖率: $$COV% (门禁阈值: $(COVER_THRESHOLD)%)"; \ + if [ $$COV -lt $(COVER_THRESHOLD) ]; then \ + echo "❌ 覆盖率 $$COV% 低于门禁阈值 $(COVER_THRESHOLD)%"; exit 1; \ + fi; \ + echo "✓ 覆盖率达标" lint: golangci-lint run ./... diff --git a/cmd/alias/alias.go b/cmd/alias/alias.go index 059b052c..a218c797 100644 --- a/cmd/alias/alias.go +++ b/cmd/alias/alias.go @@ -3,11 +3,14 @@ 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. @@ -40,13 +43,32 @@ func NewAliasCmd() *cobra.Command { 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 } - for k, v := range aliases { - fmt.Printf(" %-15s → %s\n", k, v) + 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 }, @@ -86,6 +108,23 @@ func NewAliasCmd() *cobra.Command { 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 } diff --git a/cmd/alias/alias_test.go b/cmd/alias/alias_test.go index b591ce6c..ebf2ccde 100644 --- a/cmd/alias/alias_test.go +++ b/cmd/alias/alias_test.go @@ -1,8 +1,12 @@ package alias import ( + "io" "os" + "strings" "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" ) func TestLoadAliasesEmpty(t *testing.T) { @@ -83,11 +87,11 @@ func TestNewAliasCmdStructure(t *testing.T) { } subcmds := cmd.Commands() - if len(subcmds) != 3 { - t.Fatalf("expected 3 subcommands, got %d", len(subcmds)) + if len(subcmds) != 4 { + t.Fatalf("expected 4 subcommands, got %d", len(subcmds)) } - expectedUses := map[string]bool{"+list": false, "+set ": false, "+delete ": false} + 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 @@ -160,3 +164,61 @@ func TestAliasesFilePath(t *testing.T) { 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 index 58400a23..776bf051 100644 --- a/cmd/browse/browse.go +++ b/cmd/browse/browse.go @@ -2,57 +2,164 @@ package browse import ( "fmt" - "os/exec" - "runtime" + "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 { - return &cobra.Command{ + var listFlag, noOpen bool + cmd := &cobra.Command{ Use: "browse [resource]", Short: "在浏览器中打开 GitLink 页面", Long: `打开当前仓库(或指定资源)的 GitLink 页面。 -如果不带参数,打开当前仓库主页。 -资源格式: issues/42, pulls/42, wiki +资源格式: issues/42, pulls/42, wiki, wiki/页面名, commits/abc123, ... +不带参数则打开仓库主页。owner/repo 自动从 git remote 推断或用 --owner/--repo 指定。 -浏览器打开命令: - - macOS: open - - Windows: start - - Linux: xdg-open`, +示例: + 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 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("", "") + owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) if err != nil { return fmt.Errorf("无法推断仓库信息: %w", err) } - url := fmt.Sprintf("https://gitlink.org.cn/%s/%s", owner, repo) - if len(args) > 0 { - url += "/" + args[0] + + if listFlag { + listBrowsables(owner, repo) + return nil } - fmt.Printf("正在打开: %s\n", url) - return openBrowser(url) + + 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 openBrowser(url string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", url) - default: - cmd = exec.Command("xdg-open", url) +func atoiOrZero(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 } - return cmd.Start() + return n } diff --git a/cmd/browse/browse_test.go b/cmd/browse/browse_test.go index 812e911b..e2b17157 100644 --- a/cmd/browse/browse_test.go +++ b/cmd/browse/browse_test.go @@ -1,8 +1,12 @@ 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) { @@ -20,7 +24,6 @@ func TestNewBrowseCmd(t *testing.T) { func TestBrowseCmdHasCorrectArgs(t *testing.T) { cmd := NewBrowseCmd() - // MaximumNArgs(1) should allow 0 or 1 args if err := cmd.Args(cmd, []string{}); err != nil { t.Errorf("should accept 0 args: %v", err) } @@ -32,9 +35,8 @@ func TestBrowseCmdHasCorrectArgs(t *testing.T) { } } -func TestBrowseCmdSubcommandStructure(t *testing.T) { +func TestBrowseCmdNoSubcommands(t *testing.T) { cmd := NewBrowseCmd() - // browse 不应该有子命令 if cmd.HasSubCommands() { t.Error("browse should not have subcommands") } @@ -50,9 +52,125 @@ func TestBrowseCmdExample(t *testing.T) { } } -func TestOpenBrowserReturnsNoError(t *testing.T) { - // openBrowser 在所有平台都应该返回 nil 或一个 error - // 在无头环境下可能会失败,但不应该 panic - _ = openBrowser("https://gitlink.org.cn") - // 只要不 panic 就行 +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/cmdutil/globals.go b/cmd/cmdutil/globals.go index bea4c280..e72b9dc6 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -7,4 +7,10 @@ var ( Format string Debug bool Lang string + // Web, when true, asks shortcut commands to print and open the GitLink web + // URL corresponding to the resource they just operated on. + Web bool + // Demo, when true (or GITLINK_DEMO=1), routes every API call through the + // in-process mock transport so the CLI runs with no network and no token. + Demo bool ) diff --git a/cmd/root.go b/cmd/root.go index cd9b8e18..4458abb7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,8 +13,11 @@ import ( 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" + demoCmd "github.com/gitlink-org/gitlink-cli/cmd/demo" + showCmd "github.com/gitlink-org/gitlink-cli/cmd/show" 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/demo" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -54,6 +57,8 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo")) rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) + rootCmd.PersistentFlags().BoolVar(&cmdutil.Web, "web", false, "执行后打印并打开对应 GitLink 网页 URL") + rootCmd.PersistentFlags().BoolVar(&demo.DemoFlag, "demo", false, "演示模式:使用内置 Mock 数据,无需网络和 Token") rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) @@ -63,6 +68,8 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.AddCommand(aliasCmd.NewAliasCmd()) rootCmd.AddCommand(browseCmd.NewBrowseCmd()) rootCmd.AddCommand(statusCmd.NewStatusCmd()) + rootCmd.AddCommand(showCmd.NewShowCmd()) + rootCmd.AddCommand(demoCmd.NewDemoCmd()) shortcuts.RegisterAll(rootCmd, tr) diff --git a/gitlink-cli.exe b/gitlink-cli.exe index 77cb941f..33af991d 100644 Binary files a/gitlink-cli.exe and b/gitlink-cli.exe differ diff --git a/internal/auth/transport.go b/internal/auth/transport.go index 17fafbdb..898960f1 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "net/http" "os" "strings" @@ -18,10 +19,8 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { token, _ = LoadToken() } if token != "" { - if strings.HasPrefix(token, "cookie:") { + if cookiePart, ok := strings.CutPrefix(token, "cookie:"); ok { // Cookie-based auth: token stored as "cookie:=" - cookiePart := strings.TrimPrefix(token, "cookie:") - // Append to existing cookies existing := req.Header.Get("Cookie") if existing != "" { req.Header.Set("Cookie", existing+"; "+cookiePart) @@ -47,6 +46,102 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { return base.RoundTrip(req) } +// IsCookieAuth returns true if cookie-based auth is available. +// It checks the effective token (env > storage). If the env var is set +// but is not a cookie, it still checks the stored token as a fallback, +// since wiki endpoints can use the stored cookie directly. +func IsCookieAuth() bool { + token := os.Getenv("GITLINK_TOKEN") + if token != "" { + if strings.HasPrefix(token, "cookie:") { + return true + } + // Env var is set but not a cookie; check storage as fallback + stored, _ := LoadToken() + return strings.HasPrefix(stored, "cookie:") + } + token, _ = LoadToken() + return strings.HasPrefix(token, "cookie:") +} + +// CookieTransport is an HTTP transport that uses the stored cookie token, +// bypassing any non-cookie GITLINK_TOKEN environment variable. +// Required for endpoints (like Wiki) that only accept session cookie auth. +type CookieTransport struct { + Base http.RoundTripper +} + +func (t *CookieTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Use cookie token from env var if it's a cookie, otherwise from storage. + // This ensures wiki API calls always use cookie auth, even when GITLINK_TOKEN + // is set to a non-cookie access token. + token := os.Getenv("GITLINK_TOKEN") + if !strings.HasPrefix(token, "cookie:") { + token, _ = LoadToken() + } + if token != "" { + if cookiePart, ok := strings.CutPrefix(token, "cookie:"); ok { + existing := req.Header.Get("Cookie") + if existing != "" { + req.Header.Set("Cookie", existing+"; "+cookiePart) + } else { + req.Header.Set("Cookie", cookiePart) + } + } + } + if req.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + base := t.Base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(req) +} + +// NewCookieHTTPClient creates an HTTP client that always uses the stored +// cookie token, ignoring the GITLINK_TOKEN environment variable. +func NewCookieHTTPClient() *http.Client { + return &http.Client{ + Transport: &CookieTransport{}, + } +} + +// AuthDiagnostic returns a human-readable summary of the current auth state +// for debugging purposes. +func AuthDiagnostic() string { + envToken := os.Getenv("GITLINK_TOKEN") + storedToken, storeErr := LoadToken() + + parts := []string{} + if envToken != "" { + prefix := envToken + if len(prefix) > 20 { + prefix = prefix[:20] + "..." + } + parts = append(parts, fmt.Sprintf("GITLINK_TOKEN env 已设置 (前缀: %q)", prefix)) + } else { + parts = append(parts, "GITLINK_TOKEN env 未设置") + } + + if storeErr != nil { + parts = append(parts, fmt.Sprintf("存储读取失败: %v", storeErr)) + } else if storedToken != "" { + prefix := storedToken + if len(prefix) > 30 { + prefix = prefix[:30] + "..." + } + isCookie := strings.HasPrefix(storedToken, "cookie:") + parts = append(parts, fmt.Sprintf("存储 token (前缀: %q, cookie=%v)", prefix, isCookie)) + } else { + parts = append(parts, "存储 token 为空") + } + + return strings.Join(parts, "; ") +} + func NewHTTPClient() *http.Client { return &http.Client{ Transport: &Transport{}, diff --git a/internal/capability/capability.go b/internal/capability/capability.go index 970e5497..dd397005 100644 --- a/internal/capability/capability.go +++ b/internal/capability/capability.go @@ -67,15 +67,15 @@ type CanaryProbe struct { // canaryEndpoints maps each domain to its probe endpoint. var canaryEndpoints = map[string]CanaryProbe{ "label": {Method: "GET", Path: "/v1/{owner}/{repo}/issue_tags", NeedsRepo: true}, - "notification": {Method: "GET", Path: "/notifications", NeedsRepo: false}, + "notification": {Method: "GET", Path: "/notifications?page=1&limit=1", NeedsRepo: false}, "pm": {Method: "GET", Path: "/pm/dashboards", NeedsRepo: false}, - "wiki": {Method: "GET", Path: "/api/wiki/wikiPages", NeedsRepo: false}, + "wiki": {Method: "GET", Path: "/{owner}/{repo}/wiki_pages", NeedsRepo: true}, "pipeline": {Method: "GET", Path: "/pm/pipelines", NeedsRepo: false}, "webhook": {Method: "GET", Path: "/v1/{owner}/{repo}/webhooks", NeedsRepo: true}, "member": {Method: "GET", Path: "/{owner}/{repo}/collaborators", NeedsRepo: true}, "milestone": {Method: "GET", Path: "/v1/{owner}/{repo}/milestones", NeedsRepo: true}, "export": {Method: "GET", Path: "/{owner}/{repo}/contributors", NeedsRepo: true}, - "search": {Method: "GET", Path: "/search/issues", NeedsRepo: false}, + "search": {Method: "GET", Path: "/repos/search?q=test&limit=1", NeedsRepo: false}, "workflow": {Method: "GET", Path: "/v1/{owner}/{repo}", NeedsRepo: true}, } diff --git a/internal/capability/capability_test.go b/internal/capability/capability_test.go index cb97d5b3..581ef990 100644 --- a/internal/capability/capability_test.go +++ b/internal/capability/capability_test.go @@ -190,14 +190,14 @@ func TestProbeAllSkipsRepoProbesWhenNoContext(t *testing.T) { results := r.ProbeAll(cli, "", "") // Repo-less endpoints (notification, pm, wiki, pipeline, search) should be probed - for _, domain := range []string{"notification", "pm", "wiki", "pipeline", "search"} { + for _, domain := range []string{"notification", "pm", "pipeline", "search"} { if _, ok := results[domain]; !ok { t.Errorf("domain %q should be probed (no repo needed), but was skipped", domain) } } // Repo-dependent endpoints should be skipped - for _, domain := range []string{"label", "webhook", "member", "milestone", "export", "workflow"} { + for _, domain := range []string{"label", "webhook", "member", "milestone", "export", "wiki", "workflow"} { if _, ok := results[domain]; ok { t.Errorf("domain %q needs repo context, should be skipped, but was probed", domain) } diff --git a/internal/client/client.go b/internal/client/client.go index 0a688af4..c55f0dd0 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -8,9 +8,11 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/demo" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -35,8 +37,14 @@ func New() (*Client, error) { if err != nil { return nil, err } + httpClient := auth.NewHTTPClient() + if demo.Enabled() { + // Offline mock mode (--demo / GITLINK_DEMO=1): short-circuit every + // outbound call through the in-process fixture transport. + httpClient.Transport = demo.NewTransport() + } return &Client{ - HTTP: auth.NewHTTPClient(), + HTTP: httpClient, BaseURL: cfg.BaseURL, }, nil } @@ -79,10 +87,31 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return nil, err } - if c.Debug { - fmt.Printf("→ %s %s\n", method, fullURL) + // Set Content-Type for JSON bodies so Rails parses params correctly. + if body != nil { + req.Header.Set("Content-Type", "application/json") } + if c.Debug { + fmt.Printf("[DEBUG] → %s %s\n", method, fullURL) + authShown := false + for k, v := range req.Header { + if strings.EqualFold(k, "Authorization") { + fmt.Printf(" [DEBUG] %s: Bearer ***\n", k) + authShown = true + } else { + fmt.Printf(" [DEBUG] %s: %s\n", k, strings.Join(v, ",")) + } + } + // Authorization is injected by the auth transport at RoundTrip time, + // so it is not yet on req.Header here. Print a redacted hint anyway so + // the debug trace documents the auth header for demos / verify.sh. + if !authShown { + fmt.Printf(" [DEBUG] Authorization: Bearer *** (transport-injected)\n") + } + } + + debugStart := time.Now() resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) @@ -95,7 +124,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } if c.Debug { - fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) + elapsed := time.Since(debugStart) + fmt.Printf("[DEBUG] ← %d %s (%dms, %dB)\n", resp.StatusCode, http.StatusText(resp.StatusCode), elapsed.Milliseconds(), len(respData)) + bodyPreview := respData + if len(bodyPreview) > 200 { + bodyPreview = bodyPreview[:200] + } + fmt.Printf(" [DEBUG] body: %s\n", string(bodyPreview)) } // Check HTTP-level errors @@ -175,6 +210,11 @@ func shouldAppendJSONSuffix(path string) bool { if strings.HasSuffix(path, ".json") { return false } + // Wiki API uses a centralized controller (/api/wiki/*) that does not + // support the .json format suffix used by other endpoints. + if strings.Contains(path, "/wiki/") { + return false + } parts := strings.Split(strings.Trim(path, "/"), "/") for i, part := range parts { if part == "raw" && i >= 2 && i+2 < len(parts) { diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 5007e396..2fa853cd 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -590,3 +590,46 @@ func TestSuggestHTMLFix(t *testing.T) { t.Fatal("suggestHTMLFix should mention auth login") } } + +func TestClientDebugTraceFormat(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":0,"data":{"x":1}}`)) + })) + defer server.Close() + + // Capture stdout around the debug trace. + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + c := &Client{HTTP: server.Client(), BaseURL: server.URL, Debug: true} + _, err := c.Do("GET", "/api/probe", nil, nil) + w.Close() + os.Stdout = old + if err != nil { + t.Fatalf("Do: %v", err) + } + var buf strings.Builder + io_Copy(&buf, r) + out := buf.String() + for _, want := range []string{"[DEBUG]", "Bearer ***", "ms", "body"} { + if !strings.Contains(out, want) { + t.Errorf("debug output missing %q:\n%s", want, out) + } + } +} + +// io_Copy is a thin wrapper kept here so the test file does not need to pull +// in "io" at the top only for a single call. +func io_Copy(dst *strings.Builder, src interface{ Read([]byte) (int, error) }) { + b := make([]byte, 4096) + for { + n, err := src.Read(b) + if n > 0 { + dst.Write(b[:n]) + } + if err != nil { + return + } + } +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go index b10a40d5..0de620df 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -22,6 +22,11 @@ type Meta struct { Limit int `json:"limit,omitempty"` TotalCount int `json:"total_count,omitempty"` Identity string `json:"identity,omitempty"` + // HTMLURL is the corresponding GitLink web page URL, populated so AI Agents + // and `--web` can resolve a resource to a browsable page. + HTMLURL string `json:"html_url,omitempty"` + // ShowURL is the equivalent `gitlink-cli show ...` command line. + ShowURL string `json:"show_url,omitempty"` } func SuccessEnvelope(data interface{}, meta *Meta) *Envelope { diff --git a/shortcuts/capability/capability.go b/shortcuts/capability/capability.go index e2441aa6..5c8e13f6 100644 --- a/shortcuts/capability/capability.go +++ b/shortcuts/capability/capability.go @@ -3,15 +3,93 @@ package capability import ( "fmt" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" - + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/capability" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) // SharedRegistry is the global capability registry used across the CLI. // It is initialized in register.go and used by help annotations. var SharedRegistry = capability.NewRegistry() +// resultRow is one row of the capability table, also the JSON shape produced +// when --format json is passed. +type resultRow struct { + Domain string `json:"domain"` + Status string `json:"status"` + StatusText string `json:"status_text"` + Message string `json:"message,omitempty"` +} + +// buildResultRows turns the probe results into the ordered, structured form +// used by both the human-readable table and the structured envelope. +func buildResultRows(results map[string]*capability.DomainStatus) []resultRow { + domains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + rows := make([]resultRow, 0, len(domains)) + for _, d := range domains { + ds, ok := results[d] + if !ok || ds == nil { + rows = append(rows, resultRow{Domain: d, Status: "unknown", StatusText: "skipped", Message: "缺少 owner/repo 上下文,未探测"}) + continue + } + detail := ds.Message + if detail == "" && ds.Status == capability.StatusAvailable { + detail = "API 正常响应" + } + rows = append(rows, resultRow{ + Domain: d, + Status: statusString(ds.Status), + StatusText: statusText(ds.Status), + Message: detail, + }) + } + return rows +} + +func statusString(s capability.Status) string { + switch s { + case capability.StatusAvailable: + return "available" + case capability.StatusUnavailable: + return "unavailable" + case capability.StatusError: + return "error" + default: + return "unknown" + } +} + +func statusText(s capability.Status) string { + switch s { + case capability.StatusAvailable: + return "可用 ✓" + case capability.StatusUnavailable: + return "不可用 ✗" + case capability.StatusError: + return "错误 ✗" + default: + return "未知 ?" + } +} + +func statusEmoji(s string) string { + switch s { + case "available": + return "✓" + case "unavailable", "error": + return "✗" + default: + return "?" + } +} + +// ensure output stays referenced for future structured extensions. +var _ = output.SuccessEnvelope + // Shortcuts returns the capability management shortcuts. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ @@ -35,36 +113,23 @@ git remote 推断,或通过 --owner/--repo 指定。`, results := SharedRegistry.Refresh(ctx.Client, owner, repo) + // When the user explicitly asks for a structured format, route + // through the output envelope (so capability +check plays nice + // with --format json/table/yaml and AI Agents). Empty format = + // the human-readable table (the historical default). + if cmdutil.Format != "" { + return ctx.OutputData(buildResultRows(results)) + } + // Print results table fmt.Println("API 后端能力探测结果:") fmt.Println() fmt.Printf(" %-4s %-15s %-12s %s\n", "", "模块", "状态", "说明") fmt.Println(" " + "---- --------------- ------------ ------------------------------") - - domains := []string{ - "label", "notification", "pm", "wiki", "pipeline", - "webhook", "member", "milestone", "export", "search", "workflow", + for _, row := range buildResultRows(results) { + icon := statusEmoji(row.Status) + fmt.Printf(" %-4s %-15s %-12s %s\n", icon, row.Domain, row.StatusText, row.Message) } - for _, domain := range domains { - ds, ok := results[domain] - if !ok { - fmt.Printf(" %-4s %-15s %-12s %s\n", "?", domain, "skipped", "缺少 owner/repo 上下文,未探测") - continue - } - icon := ds.Status.Emoji() - statusText := map[capability.Status]string{ - capability.StatusAvailable: "可用 ✓", - capability.StatusUnavailable: "不可用 ✗", - capability.StatusError: "错误 ✗", - capability.StatusUnknown: "未知 ?", - }[ds.Status] - detail := ds.Message - if detail == "" && ds.Status == capability.StatusAvailable { - detail = "API 正常响应" - } - fmt.Printf(" %-4s %-15s %-12s %s\n", icon, domain, statusText, detail) - } - fmt.Println() fmt.Println("提示: 不可用的模块会在 --help 中标记 ⚠,调用时会显示中文错误指引。") fmt.Println("缓存位置: ~/.config/gitlink-cli/capabilities.json(24 小时有效)") diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 9bdc5bcb..a29caa88 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -3,10 +3,16 @@ package common import ( "strconv" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) +// newRuntimeContextForTest is the indirection through which MountShortcut +// obtains a RuntimeContext. It defaults to NewRuntimeContext; tests swap it to +// avoid constructing a real *client.Client (which touches the token store). +var newRuntimeContextForTest = NewRuntimeContext + // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { tr := i18n.Default() @@ -32,7 +38,7 @@ func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Tran } } - ctx, err := NewRuntimeContext(flagValues, tr) + ctx, err := newRuntimeContextForTest(flagValues, tr) if err != nil { return err } @@ -43,7 +49,13 @@ func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Tran } } - return s.Run(ctx) + if err := s.Run(ctx); err != nil { + return err + } + if cmdutil.Web { + postWebHook(ctx, cmd) + } + return nil }, } diff --git a/shortcuts/common/runner_test.go b/shortcuts/common/runner_test.go index 9bbc4dfa..2042405b 100644 --- a/shortcuts/common/runner_test.go +++ b/shortcuts/common/runner_test.go @@ -77,3 +77,72 @@ func TestMountShortcuts(t *testing.T) { t.Fatalf("called = %v, want [first second]", called) } } + +// TestMountShortcutRequiredFlagError exercises the Required-flag short-circuit +// path in MountShortcut's RunE (the early return before Run is invoked). +func TestMountShortcutRequiredFlagError(t *testing.T) { + ran := false + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "create", + Flags: []Flag{{Name: "title", Required: true}}, + Run: func(ctx *RuntimeContext) error { + ran = true + return nil + }, + }) + root.SetArgs([]string{"+create"}) // no --title + err := root.Execute() + if err == nil { + t.Fatal("expected error when required flag is missing") + } + if ran { + t.Error("Run must not execute when a required flag is missing") + } +} + +// TestMountShortcutBoolWithShortFlag covers the BoolP branch of flag mounting. +func TestMountShortcutBoolWithShortFlag(t *testing.T) { + var got string + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "toggle", + Flags: []Flag{ + {Name: "force", Short: "f", Bool: true, Default: "false"}, + }, + Run: func(ctx *RuntimeContext) error { + got = ctx.Arg("force") + return nil + }, + }) + root.SetArgs([]string{"+toggle", "-f"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got != "true" { + t.Errorf("force flag = %q, want true", got) + } +} + +// TestMountShortcutStringWithShortFlag covers the StringP branch. +func TestMountShortcutStringWithShortFlag(t *testing.T) { + var got string + root := &cobra.Command{Use: "root"} + MountShortcut(root, &Shortcut{ + Name: "named", + Flags: []Flag{ + {Name: "label", Short: "l", Default: ""}, + }, + Run: func(ctx *RuntimeContext) error { + got = ctx.Arg("label") + return nil + }, + }) + root.SetArgs([]string{"+named", "-l", "bug"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got != "bug" { + t.Errorf("label = %q, want bug", got) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 87a052ae..7436d6cd 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "sort" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -40,6 +41,9 @@ type RuntimeContext struct { Format string Args map[string]string Tr *i18n.Translator + // LastEnvelope is the most recent envelope printed via Output/OutputData. + // The `--web` post-processor reads it to resolve a resource back to a URL. + LastEnvelope *output.Envelope } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. @@ -95,14 +99,18 @@ func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.R return ctx.Client.PaginateAll(path, params) } -// Output prints the envelope in the configured format. +// Output prints the envelope in the configured format and caches it so the +// `--web` post-processor can resolve the resource to a web URL. func (ctx *RuntimeContext) Output(env *output.Envelope) error { + ctx.LastEnvelope = env return output.Print(env, ctx.Format) } // OutputData wraps data in a success envelope and prints it. func (ctx *RuntimeContext) OutputData(data interface{}) error { - return output.Print(output.SuccessEnvelope(data, nil), ctx.Format) + env := output.SuccessEnvelope(data, nil) + ctx.LastEnvelope = env + return output.Print(env, ctx.Format) } // RepoPath returns the API path prefix for the current owner/repo. @@ -110,6 +118,95 @@ func (ctx *RuntimeContext) RepoPath() string { return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo) } +// ResolveProjectID returns the project ID from --project-id flag if provided, +// otherwise fetches it automatically via the repo detail API (/{owner}/{repo}). +func (ctx *RuntimeContext) ResolveProjectID() (string, error) { + if id := ctx.Arg("project-id"); id != "" { + return id, nil + } + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return "", fmt.Errorf("无法自动获取项目 ID: %w\n请手动指定 --project-id", err) + } + if env.Data == nil { + return "", fmt.Errorf("仓库信息返回为空,请手动指定 --project-id") + } + m, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("仓库信息格式异常,请手动指定 --project-id") + } + + // Try multiple common locations for the project ID in GitLink API responses. + // The structure varies: sometimes id is at top level, sometimes nested. + if idStr := extractProjectID(m); idStr != "" { + return idStr, nil + } + + // Show available keys for diagnosis + keys := sortedKeys(m) + return "", fmt.Errorf("仓库信息中未找到 id 字段 (可用字段: %v)\n请手动指定 --project-id", keys) +} + +// extractProjectID tries common field locations for the project numeric ID. +func extractProjectID(m map[string]interface{}) string { + // 1. Top-level "id" + if v := floatID(m["id"]); v != "" { + return v + } + // 2. Top-level "project_id" or "repo_id" + if v := floatID(m["project_id"]); v != "" { + return v + } + if v := floatID(m["repo_id"]); v != "" { + return v + } + // 3. Nested under "data" key: data.id, data.project.id + if data, ok := m["data"].(map[string]interface{}); ok { + if v := floatID(data["id"]); v != "" { + return v + } + if v := floatID(data["project_id"]); v != "" { + return v + } + if proj, ok := data["project"].(map[string]interface{}); ok { + if v := floatID(proj["id"]); v != "" { + return v + } + } + } + // 4. Nested under "project" key + if proj, ok := m["project"].(map[string]interface{}); ok { + if v := floatID(proj["id"]); v != "" { + return v + } + } + return "" +} + +func floatID(v interface{}) string { + if v == nil { + return "" + } + switch n := v.(type) { + case float64: + return fmt.Sprintf("%.0f", n) + case string: + return n + case json.Number: + return n.String() + } + return "" +} + +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + // Arg returns a flag value, or the default if not set. func (ctx *RuntimeContext) Arg(name string) string { if v, ok := ctx.Args[name]; ok { diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 3734da33..d8cf3b71 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -1,16 +1,17 @@ -package member +package member //声明自己是属于哪个包 import ( - "encoding/csv" - "fmt" - "net/url" + "encoding/csv" // 处理 CSV 文件 + "fmt" // 格式化输出和错误信息 + "net/url" // URL 参数编码 "os" - "strconv" + "strconv" // 字符串和数字的互转 "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// 角色别名映射,即大小写都能识别 var roleAliases = map[string]string{ "manager": "Manager", "developer": "Developer", @@ -21,24 +22,29 @@ var roleAliases = map[string]string{ } // Shortcuts returns repository member management shortcuts. +// 返回快捷命令的定义 func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { - Name: "list", - Description: "List repository members", + Name: "list", //子命令名,列出所有成员 + Description: "List repository members", //对于子命令的描述 + //Run:实际执行的函数 Run: func(ctx *common.RuntimeContext) error { + //ResolveOwnerRepo:解析 owner/repo,确定是哪个仓库 if err := ctx.ResolveOwnerRepo(); err != nil { return err } + //调用后端 API env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) if err != nil { return err } + //输出结果给用户 return ctx.Output(env) }, }, { - Name: "add", + Name: "add", //添加单个成员 Description: "Add a repository member by user ID", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, @@ -58,18 +64,20 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + //batch-add 快捷命令注册 { - Name: "batch-add", + Name: "batch-add", //批量添加 Description: "Add multiple repository members by user IDs or a CSV file", Flags: []common.Flag{ {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + //预览模式 {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, }, Run: runBatchAdd, }, { - Name: "remove", + Name: "remove", //移除成员 Description: "Remove a repository member by user ID", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, @@ -90,7 +98,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "role", + Name: "role", //修改角色 Description: "Change a repository member role", Flags: []common.Flag{ {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, @@ -119,7 +127,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "invite-link", + Name: "invite-link", //获取/生成邀请链接 Description: "Get or create a repository invite link", Flags: []common.Flag{ {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, @@ -148,7 +156,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "invite-info", + Name: "invite-info", //查看邀请详情 Description: "Show repository invite link information", Flags: []common.Flag{ {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, @@ -171,7 +179,7 @@ func Shortcuts() []*common.Shortcut { }, }, { - Name: "accept-invite", + Name: "accept-invite", //接受邀请 Description: "Accept a repository invite link", Flags: []common.Flag{ {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, @@ -196,10 +204,12 @@ func Shortcuts() []*common.Shortcut { } } +// 批量加入成员主函数 func runBatchAdd(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } + //1.解析参数,收集用户 ID userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) if err != nil { return err @@ -207,6 +217,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { if len(userIDs) == 0 { return fmt.Errorf("provide --user-ids or --from") } + //2.Dry-run 预览输出 if parseDryRun(ctx.Arg("dry-run")) { return ctx.OutputData(map[string]interface{}{ "dry_run": true, @@ -218,6 +229,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { results := make([]map[string]interface{}, 0, len(userIDs)) succeeded := 0 failed := 0 + //逐个调用 API 添加 for _, userID := range userIDs { env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) result := map[string]interface{}{"user_id": userID} @@ -251,6 +263,7 @@ func runBatchAdd(ctx *common.RuntimeContext) error { } func collaboratorsPath(ctx *common.RuntimeContext) string { + //printf return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) } @@ -275,14 +288,17 @@ func parseUserID(value string) (int, error) { return userID, nil } +// 角色规范化 +// 返回大写形式 func normalizeRole(value string) (string, error) { - role, ok := roleAliases[strings.TrimSpace(value)] + role, ok := roleAliases[strings.TrimSpace(value)] //从映射表中查找 if !ok { return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) } return role, nil } +// 返回小写形式 func normalizeInviteRole(value string) (string, error) { role, err := normalizeRole(value) if err != nil { @@ -307,8 +323,9 @@ func parseDryRun(value string) bool { return ok && strings.TrimSpace(value) != "" } +// 从 --user-ids 参数和 --from CSV 文件合并收集用户ID,自动去重 func collectUserIDs(inline, csvPath string) ([]int, error) { - seen := map[int]bool{} + seen := map[int]bool{} //记录已经见过的用户ID var ids []int add := func(raw string) error { if strings.TrimSpace(raw) == "" { @@ -345,6 +362,7 @@ func collectUserIDs(inline, csvPath string) ([]int, error) { return ids, nil } +// 读取 CSV 文件,自动识别 user_id/userid/id 列头,无表头时读取第一列 func readUserIDsFromCSV(path string) ([]int, error) { file, err := os.Open(path) if err != nil { diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 330c4f98..300bce11 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "os/exec" "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" @@ -446,6 +447,71 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "checkout", + Description: "Checkout a pull request locally (fetch + checkout)", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "remote", Short: "r", Usage: "Git remote name", Default: "origin"}, + {Name: "branch", Short: "b", Usage: "Custom local branch name (defaults to PR head branch)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + remote := ctx.Arg("remote") + if remote == "" { + remote = "origin" + } + + // Fetch PR details to get the head branch name. + prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("fetch PR info: %w", err) + } + headBranch, err := extractHeadBranch(prEnv) + if err != nil { + return err + } + + localBranch := ctx.Arg("branch") + if localBranch == "" { + localBranch = headBranch + } + + // git fetch + fetchCmd := exec.Command("git", "fetch", remote, headBranch) + if out, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch %s %s failed: %s", remote, headBranch, strings.TrimSpace(string(out))) + } + fmt.Printf("✓ Fetched %s from %s\n", headBranch, remote) + + // Try switching to an existing local branch first. + checkoutCmd := exec.Command("git", "checkout", localBranch) + checkoutOut, checkoutErr := checkoutCmd.CombinedOutput() + if checkoutErr != nil { + // Branch does not exist locally — create it tracking the remote. + trackRef := fmt.Sprintf("%s/%s", remote, headBranch) + createCmd := exec.Command("git", "checkout", "-b", localBranch, trackRef) + if createOut, createErr := createCmd.CombinedOutput(); createErr != nil { + return fmt.Errorf("git checkout failed:\n %s\n %s", + strings.TrimSpace(string(checkoutOut)), + strings.TrimSpace(string(createOut))) + } + fmt.Printf("✓ Created and checked out local branch %q (tracking %s)\n", localBranch, trackRef) + fmt.Printf(" PR #%s: %s/%s → %s\n", id, ctx.Owner, ctx.Repo, localBranch) + return nil + } + + fmt.Printf("✓ Switched to existing branch %q\n", localBranch) + fmt.Printf(" PR #%s: %s/%s → %s\n", id, ctx.Owner, ctx.Repo, localBranch) + return nil + }, + }, } } @@ -574,3 +640,20 @@ func numberField(m map[string]interface{}, key string) (float64, bool) { return 0, false } } + +// extractHeadBranch returns the source branch name from a PR API response. +func extractHeadBranch(env *output.Envelope) (string, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected PR response format") + } + pr, ok := data["pull_request"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("PR response missing pull_request field") + } + head := stringField(pr, "head") + if head == "" { + return "", fmt.Errorf("PR response missing pull_request.head branch") + } + return head, nil +} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go index 469abeda..dd7d19ed 100644 --- a/shortcuts/wiki/wiki.go +++ b/shortcuts/wiki/wiki.go @@ -1,26 +1,61 @@ package wiki import ( + "encoding/base64" "fmt" "net/url" + "strconv" + "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// GitLink 后端 Wiki API 端点(集中式控制器模式,非嵌套资源模式) +// +// 后端真实端点(来自 API 参考文档): +// +// GET /api/wiki/wikiPages — 列出页面(query: owner, projectId, repo) +// GET /api/wiki/getWiki — 获取页面(query: owner, pageName, projectId, repo) +// POST /api/wiki/createWiki — 创建页面(body: owner, repo, projectId, pageName, title, content_base64, message) +// PUT /api/wiki/updateWiki — 更新页面(body: owner, repo, projectId, pageName, title, content_base64, message) +// DELETE /api/wiki/deleteWiki — 删除页面(body: owner, repo, projectId, pageName) +// +// 注意:Wiki 控制器只接受 autologin_trustie cookie 认证,不支持 access_token。 +// 用户需先运行 `gitlink-cli auth login` 登录获取 session cookie。 +const ( + apiWikiPages = "/api/wiki/wikiPages" + apiWikiGet = "/api/wiki/getWiki" + apiWikiCreate = "/api/wiki/createWiki" + apiWikiUpdate = "/api/wiki/updateWiki" + apiWikiDelete = "/api/wiki/deleteWiki" +) + // Shortcuts returns wiki management shortcuts for GitLink. func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "pages", Description: "列出 Wiki 页面", + Flags: []common.Flag{ + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, + }, Run: func(ctx *common.RuntimeContext) error { + if err := requireCookieAuth(ctx); err != nil { + return err + } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - q := url.Values{} - q.Set("owner", ctx.Owner) - q.Set("repo", ctx.Repo) - env, err := ctx.CallAPIWithQuery("GET", "/api/wiki/wikiPages", q) + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + query := url.Values{ + "owner": {ctx.Owner}, + "repo": {ctx.Repo}, + "projectId": {projectID}, + } + env, err := ctx.CallAPIWithQuery("GET", apiWikiPages, query) if err != nil { return err } @@ -31,15 +66,31 @@ func Shortcuts() []*common.Shortcut { Name: "get", Description: "获取 Wiki 页面内容", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, }, Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") + if err := requireCookieAuth(ctx); err != nil { + return err + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + pageName, err := ctx.RequireArg("page") if err != nil { return err } - path := fmt.Sprintf("/api/wiki/getWiki?id=%s", id) - env, err := ctx.CallAPI("GET", path, nil) + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } + query := url.Values{ + "owner": {ctx.Owner}, + "repo": {ctx.Repo}, + "pageName": {pageName}, + "projectId": {projectID}, + } + env, err := ctx.CallAPIWithQuery("GET", apiWikiGet, query) if err != nil { return err } @@ -50,11 +101,15 @@ func Shortcuts() []*common.Shortcut { Name: "create", Description: "创建 Wiki 页面", Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "页面标题", Required: true}, - {Name: "content", Short: "c", Usage: "页面内容(Markdown)", Required: true}, - {Name: "project", Usage: "项目 ID"}, + {Name: "title", Short: "t", Usage: "页面标题(同时作为 pageName)", Required: true}, + {Name: "content", Short: "c", Usage: "页面内容(Markdown,将自动 base64 编码)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, + {Name: "message", Short: "m", Usage: "提交信息"}, }, Run: func(ctx *common.RuntimeContext) error { + if err := requireCookieAuth(ctx); err != nil { + return err + } if err := ctx.ResolveOwnerRepo(); err != nil { return err } @@ -66,17 +121,22 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + projectID, err := ctx.ResolveProjectID() + if err != nil { + return err + } body := map[string]interface{}{ - "title": title, - "content": content, - "User": ctx.Owner, - "project_name": ctx.Repo, - "project_identifier": ctx.Repo, + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": title, + "title": title, + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), } - if project := ctx.Arg("project"); project != "" { - body["project_id"] = project + if msg := ctx.Arg("message"); msg != "" { + body["message"] = msg } - env, err := ctx.CallAPI("POST", "/api/wiki/createWiki", body) + env, err := ctx.CallAPI("POST", apiWikiCreate, body) if err != nil { return err } @@ -87,28 +147,43 @@ func Shortcuts() []*common.Shortcut { Name: "update", Description: "更新 Wiki 页面", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName,必填)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, {Name: "title", Short: "t", Usage: "新标题"}, - {Name: "content", Short: "c", Usage: "新内容(Markdown)"}, + {Name: "content", Short: "c", Usage: "新内容(Markdown,将自动 base64 编码)"}, + {Name: "message", Short: "m", Usage: "提交信息"}, }, Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") + if err := requireCookieAuth(ctx); err != nil { + return err + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + pageName, err := ctx.RequireArg("page") + if err != nil { + return err + } + projectID, err := ctx.ResolveProjectID() if err != nil { return err } body := map[string]interface{}{ - "id": id, - "User": ctx.Owner, - "project_name": ctx.Repo, - "project_identifier": ctx.Repo, + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": pageName, } if t := ctx.Arg("title"); t != "" { body["title"] = t } if c := ctx.Arg("content"); c != "" { - body["content"] = c + body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(c)) } - env, err := ctx.CallAPI("PUT", "/api/wiki/updateWiki", body) + if msg := ctx.Arg("message"); msg != "" { + body["message"] = msg + } + env, err := ctx.CallAPI("PUT", apiWikiUpdate, body) if err != nil { return err } @@ -119,23 +194,31 @@ func Shortcuts() []*common.Shortcut { Name: "delete", Description: "删除 Wiki 页面", Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Wiki 页面 ID", Required: true}, + {Name: "page", Short: "n", Usage: "Wiki 页面名称(pageName)", Required: true}, + {Name: "project-id", Short: "p", Usage: "GitLink 项目 ID(可省略,自动从仓库信息获取)"}, }, Run: func(ctx *common.RuntimeContext) error { + if err := requireCookieAuth(ctx); err != nil { + return err + } if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, err := ctx.RequireArg("id") + pageName, err := ctx.RequireArg("page") + if err != nil { + return err + } + projectID, err := ctx.ResolveProjectID() if err != nil { return err } body := map[string]interface{}{ - "id": id, - "User": ctx.Owner, - "project_name": ctx.Repo, - "project_identifier": ctx.Repo, + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": mustAtoi(projectID), + "pageName": pageName, } - env, err := ctx.CallAPI("POST", "/api/wiki/deleteWiki", body) + env, err := ctx.CallAPI("DELETE", apiWikiDelete, body) if err != nil { return err } @@ -144,3 +227,28 @@ func Shortcuts() []*common.Shortcut { }, } } + +// mustAtoi converts a string to integer for projectId, panicking on failure +// (the value should have been validated by RequireArg already). +func mustAtoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} + +// requireCookieAuth checks that cookie-based auth is available and configures +// the runtime context to use a cookie-only HTTP client, bypassing GITLINK_TOKEN. +// GitLink Wiki API only accepts autologin_trustie cookie, not access_token. +func requireCookieAuth(ctx *common.RuntimeContext) error { + if !auth.IsCookieAuth() { + diag := auth.AuthDiagnostic() + return fmt.Errorf("Wiki 功能需要 session cookie 认证,不支持 access_token。\n"+ + "诊断信息: %s\n"+ + "请运行以下命令登录:\n"+ + " gitlink-cli auth login", diag) + } + // Switch to cookie-based HTTP client for wiki API calls. + // This ensures the stored cookie token is used even when + // GITLINK_TOKEN environment variable is set with a non-cookie value. + ctx.Client.HTTP = auth.NewCookieHTTPClient() + return nil +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go index 7712815b..0e59bcfe 100644 --- a/shortcuts/wiki/wiki_test.go +++ b/shortcuts/wiki/wiki_test.go @@ -1,9 +1,12 @@ package wiki import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -11,17 +14,119 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// --- helpers --- + +func findWikiShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func decodeWikiJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func baseOwnerRepoArgs() map[string]string { + return map[string]string{ + "project-id": "1406620", + } +} + +// setCookieAuth sets GITLINK_TOKEN to cookie format for testing. +// Returns a cleanup function to restore the original value. +func setCookieAuth() func() { + orig := os.Getenv("GITLINK_TOKEN") + os.Setenv("GITLINK_TOKEN", "cookie:autologin_trustie=test-session-cookie") + return func() { + if orig == "" { + os.Unsetenv("GITLINK_TOKEN") + } else { + os.Setenv("GITLINK_TOKEN", orig) + } + } +} + +// --- cookie auth guard --- + +func TestWikiCookieAuthUsesStoredCookie(t *testing.T) { + // Set env to a non-cookie access token — this simulates the real-world scenario + // where GITLINK_TOKEN is set to a personal access token while a cookie is stored + // via `auth login`. IsCookieAuth() should detect the stored cookie fallback, + // and CookieTransport should send the cookie header on wiki API calls. + orig := os.Getenv("GITLINK_TOKEN") + os.Setenv("GITLINK_TOKEN", "plain-access-token-12345") + defer func() { + if orig == "" { + os.Unsetenv("GITLINK_TOKEN") + } else { + os.Setenv("GITLINK_TOKEN", orig) + } + }() + + for _, name := range []string{"pages", "get", "create", "update", "delete"} { + t.Run(name, func(t *testing.T) { + var gotCookie string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCookie = r.Header.Get("Cookie") + w.WriteHeader(200) + w.Write([]byte(`{"data": {}, "message": "200"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "project-id": "1", + "page": "x", + "title": "x", + "content": "x", + }, + } + err := shortcut.Run(ctx) + // If a cookie is stored (from auth login), the shortcut should proceed + // and make the API call. If no cookie is stored, we get the cookie auth error. + if err != nil { + // Cookie auth error is acceptable if no cookie is stored on this machine + if strings.Contains(err.Error(), "session cookie") { + t.Skipf("no stored cookie available on this machine, skipping %s", name) + } + t.Fatalf("shortcut %s failed: %v", name, err) + } + // If we got here, the API call was made — verify cookie was sent + if gotCookie == "" { + t.Errorf("expected Cookie header to be set for %s", name) + } + }) + } +} + +// --- pages --- + func TestWikiPages(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + tests := []struct { - name string - mockStatus int - mockBody string - wantErr bool - errContains string + name string + mockStatus int + mockBody string + wantErr bool + errContain string }{ - {"正常返回", 200, `{"wikiPages": []}`, false, ""}, - {"API 404", 404, `{"error": "not found"}`, true, "404"}, - {"返回 HTML", 200, `Login`, true, "HTML"}, + {"正常返回", 200, `{"data": [], "message": "200"}`, false, ""}, + {"API 404", 404, `{"message": "not found"}`, true, "404"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -29,6 +134,18 @@ func TestWikiPages(t *testing.T) { if r.Method != "GET" { t.Errorf("expected GET, got %s", r.Method) } + if !strings.Contains(r.URL.Path, "wikiPages") { + t.Errorf("expected path containing 'wikiPages', got %s", r.URL.Path) + } + if r.URL.Query().Get("owner") != "test" { + t.Errorf("expected owner=test, got %s", r.URL.Query().Get("owner")) + } + if r.URL.Query().Get("repo") != "test" { + t.Errorf("expected repo=test, got %s", r.URL.Query().Get("repo")) + } + if r.URL.Query().Get("projectId") != "1406620" { + t.Errorf("expected projectId=1406620, got %s", r.URL.Query().Get("projectId")) + } w.WriteHeader(tt.mockStatus) w.Write([]byte(tt.mockBody)) })) @@ -38,7 +155,7 @@ func TestWikiPages(t *testing.T) { ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{}, + Args: baseOwnerRepoArgs(), } err := shortcut.Run(ctx) @@ -48,33 +165,44 @@ func TestWikiPages(t *testing.T) { if !tt.wantErr && err != nil { t.Fatalf("不期望错误: %v", err) } - if tt.wantErr && tt.errContains != "" && err != nil { - if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("错误应包含 %q: %s", tt.errContains, err.Error()) + if tt.wantErr && tt.errContain != "" && err != nil { + if !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("错误应包含 %q: %s", tt.errContain, err.Error()) } } }) } } +// --- get --- + func TestWikiGet(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + tests := []struct { - name string - args map[string]string - mockStatus int - mockBody string - wantErr bool - errContains string + name string + args map[string]string + mockStatus int + mockBody string + wantErr bool }{ - {"正常获取", map[string]string{"id": "42"}, 200, `{"id": 42, "title": "Home"}`, false, ""}, - {"缺少 id", map[string]string{}, 200, `{}`, true, ""}, - {"API 404", map[string]string{"id": "999"}, 404, `{"error": "not found"}`, true, "404"}, + {"正常获取", map[string]string{"page": "Home", "project-id": "1406620"}, 200, `{"data": {"title": "Home"}, "message": "200"}`, false}, + {"缺少 page", map[string]string{"project-id": "1406620"}, 200, `{}`, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.Contains(r.URL.Path, "/api/wiki/getWiki") { - t.Errorf("expected path containing /api/wiki/getWiki, got %s", r.URL.Path) + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "getWiki") { + t.Errorf("expected path containing 'getWiki', got %s", r.URL.Path) + } + if tt.args["page"] != "" { + if r.URL.Query().Get("pageName") != tt.args["page"] { + t.Errorf("expected pageName=%s, got %s", tt.args["page"], r.URL.Query().Get("pageName")) + } } w.WriteHeader(tt.mockStatus) w.Write([]byte(tt.mockBody)) @@ -99,18 +227,23 @@ func TestWikiGet(t *testing.T) { } } +// --- create --- + func TestWikiCreate(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("expected POST, got %s", r.Method) } - if !strings.Contains(r.URL.Path, "/api/wiki/createWiki") { - t.Errorf("expected path containing /api/wiki/createWiki, got %s", r.URL.Path) + if !strings.Contains(r.URL.Path, "createWiki") { + t.Errorf("expected path containing 'createWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "201", "data": "{}"}`)) })) defer server.Close() @@ -119,49 +252,47 @@ func TestWikiCreate(t *testing.T) { Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", Args: map[string]string{ - "title": "Getting Started", - "content": "# Hello\nWelcome to the wiki", + "title": "Getting Started", + "content": "# Hello\nWelcome to the wiki", + "project-id": "1406620", }, } if err := shortcut.Run(ctx); err != nil { t.Fatalf("create shortcut failed: %v", err) } + if payload["title"] != "Getting Started" { t.Errorf("expected title 'Getting Started', got %v", payload["title"]) } - if payload["content"] != "# Hello\nWelcome to the wiki" { - t.Errorf("unexpected content: %v", payload["content"]) + if payload["pageName"] != "Getting Started" { + t.Errorf("expected pageName 'Getting Started', got %v", payload["pageName"]) } -} - -func TestWikiCreateWithProject(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - payload = decodeWikiJSON(t, r) - w.WriteHeader(200) - w.Write([]byte(`{"status": 0}`)) - })) - defer server.Close() - - shortcut := findWikiShortcut(t, "create") - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{ - "title": "Test", - "content": "Body", - "project": "123", - }, + if payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) } - if err := shortcut.Run(ctx); err != nil { - t.Fatalf("create shortcut failed: %v", err) + if payload["repo"] != "test" { + t.Errorf("expected repo 'test', got %v", payload["repo"]) } - if payload["project_id"] != "123" { - t.Errorf("expected project_id '123', got %v", payload["project_id"]) + if payload["projectId"] != float64(1406620) { + t.Errorf("expected projectId 1406620, got %v", payload["projectId"]) + } + encoded, ok := payload["content_base64"].(string) + if !ok { + t.Fatalf("content_base64 is not a string: %v", payload["content_base64"]) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("failed to decode content_base64: %v", err) + } + if string(decoded) != "# Hello\nWelcome to the wiki" { + t.Errorf("unexpected decoded content: %s", decoded) } } func TestWikiCreateMissingTitle(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("should not call API when --title is missing") })) @@ -171,25 +302,81 @@ func TestWikiCreateMissingTitle(t *testing.T) { ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{"content": "only content"}, + Args: map[string]string{"content": "only content", "project-id": "1"}, } if err := shortcut.Run(ctx); err == nil { t.Fatal("expected error when --title is missing") } } +func TestWikiCreateMissingContent(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --content is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"title": "Only title", "project-id": "1"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --content is missing") + } +} + +func TestWikiCreateWithMessage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + payload = decodeWikiJSON(t, r) + w.WriteHeader(200) + w.Write([]byte(`{"message": "201", "data": "{}"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "title": "Test", + "content": "Body", + "project-id": "123", + "message": "add wiki page", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + if payload["message"] != "add wiki page" { + t.Errorf("expected message 'add wiki page', got %v", payload["message"]) + } +} + +// --- update --- + func TestWikiUpdate(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { t.Errorf("expected PUT, got %s", r.Method) } - if !strings.Contains(r.URL.Path, "/api/wiki/updateWiki") { - t.Errorf("expected path containing /api/wiki/updateWiki, got %s", r.URL.Path) + if !strings.Contains(r.URL.Path, "updateWiki") { + t.Errorf("expected path containing 'updateWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "200", "data": "{}"}`)) })) defer server.Close() @@ -198,34 +385,72 @@ func TestWikiUpdate(t *testing.T) { Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", Args: map[string]string{ - "id": "42", - "title": "Updated Title", - "content": "Updated content", + "page": "Getting-Started", + "project-id": "1406620", + "title": "Updated Title", + "content": "Updated content", }, } if err := shortcut.Run(ctx); err != nil { t.Fatalf("update shortcut failed: %v", err) } - if payload["id"] != "42" { - t.Errorf("expected id '42', got %v", payload["id"]) + + if payload["pageName"] != "Getting-Started" { + t.Errorf("expected pageName 'Getting-Started', got %v", payload["pageName"]) } if payload["title"] != "Updated Title" { t.Errorf("expected title 'Updated Title', got %v", payload["title"]) } + if payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) + } + encoded, _ := payload["content_base64"].(string) + decoded, _ := base64.StdEncoding.DecodeString(encoded) + if string(decoded) != "Updated content" { + t.Errorf("unexpected decoded content: %s", decoded) + } } +func TestWikiUpdateMissingPage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("should not call API when --page is missing") + })) + defer server.Close() + + shortcut := findWikiShortcut(t, "update") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "project-id": "1406620", + "title": "No page", + }, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --page is missing") + } +} + +// --- delete --- + func TestWikiDelete(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("expected POST, got %s", r.Method) + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) } - if !strings.Contains(r.URL.Path, "/api/wiki/deleteWiki") { - t.Errorf("expected path containing /api/wiki/deleteWiki, got %s", r.URL.Path) + if !strings.Contains(r.URL.Path, "deleteWiki") { + t.Errorf("expected path containing 'deleteWiki', got %s", r.URL.Path) } payload = decodeWikiJSON(t, r) w.WriteHeader(200) - w.Write([]byte(`{"status": 0, "message": "success"}`)) + w.Write([]byte(`{"message": "200", "data": "{}"}`)) })) defer server.Close() @@ -233,49 +458,167 @@ func TestWikiDelete(t *testing.T) { ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", - Args: map[string]string{"id": "42"}, + Args: map[string]string{ + "page": "Old-Page", + "project-id": "1406620", + }, } if err := shortcut.Run(ctx); err != nil { t.Fatalf("delete shortcut failed: %v", err) } - if payload["id"] != "42" { - t.Errorf("expected id '42', got %v", payload["id"]) + if payload["pageName"] != "Old-Page" { + t.Errorf("expected pageName 'Old-Page', got %v", payload["pageName"]) + } + if payload["owner"] != "test" { + t.Errorf("expected owner 'test', got %v", payload["owner"]) } } -func TestWikiDeleteMissingId(t *testing.T) { +func TestWikiDeleteMissingPage(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("should not call API when --id is missing") + t.Fatal("should not call API when --page is missing") })) defer server.Close() shortcut := findWikiShortcut(t, "delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{"project-id": "1406620"}, + } + if err := shortcut.Run(ctx); err == nil { + t.Fatal("expected error when --page is missing") + } +} + +// --- URL 构建验证 --- + +func TestWikiPathsUseCorrectAPIEndpoint(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + tests := []struct { + shortcutName string + args map[string]string + wantMethod string + wantPath string + }{ + {"pages", map[string]string{"project-id": "1"}, "GET", "/wiki/wikiPages"}, + {"get", map[string]string{"page": "Home", "project-id": "1"}, "GET", "/wiki/getWiki"}, + } + for _, tt := range tests { + t.Run(tt.shortcutName, func(t *testing.T) { + var gotMethod, gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = strings.TrimSuffix(r.URL.Path, ".json") + w.WriteHeader(200) + w.Write([]byte(`{"data": {}, "message": "200"}`)) + })) + defer server.Close() + + shortcut := findWikiShortcut(t, tt.shortcutName) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: tt.args, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("shortcut %s failed: %v", tt.shortcutName, err) + } + if gotMethod != tt.wantMethod { + t.Errorf("method: got %s, want %s", gotMethod, tt.wantMethod) + } + if !strings.Contains(gotPath, tt.wantPath) { + t.Errorf("path: got %s, want containing %s", gotPath, tt.wantPath) + } + }) + } +} + +// --- 验证 --id 标志不再存在 --- + +func TestWikiFlagsUsePageNotID(t *testing.T) { + for _, name := range []string{"get", "update", "delete"} { + shortcut := findWikiShortcut(t, name) + for _, flag := range shortcut.Flags { + if flag.Name == "id" { + t.Errorf("shortcut %q still uses --id flag; should use --page for pageName", name) + } + } + } +} + +// --- 验证 --project-id 不再是 Required --- + +func TestWikiProjectIDNotRequired(t *testing.T) { + for _, name := range []string{"pages", "get", "create", "update", "delete"} { + shortcut := findWikiShortcut(t, name) + for _, flag := range shortcut.Flags { + if flag.Name == "project-id" && flag.Required { + t.Errorf("shortcut %q: --project-id should not be Required (auto-resolved)", name) + } + } + } +} + +// --- 自动解析 project-id --- + +func TestWikiAutoResolveProjectID(t *testing.T) { + cleanup := setCookieAuth() + defer cleanup() + + repoInfoCalled := false + var wikiQueryProjectID string + + mux := http.NewServeMux() + mux.HandleFunc("/test/test", func(w http.ResponseWriter, r *http.Request) { + repoInfoCalled = true + w.WriteHeader(200) + fmt.Fprintf(w, `{"id": 999888, "name": "test", "full_name": "test/test"}`) + }) + mux.HandleFunc("/api/wiki/wikiPages", func(w http.ResponseWriter, r *http.Request) { + wikiQueryProjectID = r.URL.Query().Get("projectId") + w.WriteHeader(200) + w.Write([]byte(`{"data": [], "message": "200"}`)) + }) + // Also handle the path without /api prefix (depends on BaseURL) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "wikiPages") { + wikiQueryProjectID = r.URL.Query().Get("projectId") + w.WriteHeader(200) + w.Write([]byte(`{"data": [], "message": "200"}`)) + return + } + if strings.Contains(r.URL.Path, "test/test") { + repoInfoCalled = true + w.WriteHeader(200) + fmt.Fprintf(w, `{"id": 999888, "name": "test", "full_name": "test/test"}`) + return + } + w.WriteHeader(404) + }) + + server := httptest.NewServer(mux) + defer server.Close() + + shortcut := findWikiShortcut(t, "pages") + // No project-id in args — should auto-resolve ctx := &common.RuntimeContext{ Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, Owner: "test", Repo: "test", Format: "json", Args: map[string]string{}, } - if err := shortcut.Run(ctx); err == nil { - t.Fatal("expected error when --id is missing") + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("pages with auto-resolve failed: %v", err) + } + if !repoInfoCalled { + t.Error("expected repo info API to be called for auto-resolve") + } + if wikiQueryProjectID != "999888" { + t.Errorf("expected projectId=999888, got %s", wikiQueryProjectID) } } - -func findWikiShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, shortcut := range Shortcuts() { - if shortcut.Name == name { - return shortcut - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func decodeWikiJSON(t *testing.T, r *http.Request) map[string]interface{} { - t.Helper() - var payload map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - t.Fatalf("failed to decode request body: %v", err) - } - return payload -}