新建 internal/web/ (url_builder.go + browser.go + 测试)

This commit is contained in:
蒋天翔 2026-07-07 08:43:16 +08:00
parent 6f88fd8496
commit 57c2811f48
25 changed files with 1567 additions and 233 deletions

View File

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

View File

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

View File

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

View File

@ -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 ./...

View File

@ -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 <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
},
},
)
return cmd
}

View File

@ -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 <name> <command>": false, "+delete <name>": false}
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
@ -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)
}
}
}

View File

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

View File

@ -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 <args>` with captured stdout.
func runBrowse(t *testing.T, args ...string) string {
t.Helper()
old := stdout
oldOwner, oldRepo, oldFmt := cmdutil.Owner, cmdutil.Repo, cmdutil.Format
buf := &bytes.Buffer{}
stdout = buf
defer func() {
stdout = old
cmdutil.Owner, cmdutil.Repo, cmdutil.Format = oldOwner, oldRepo, oldFmt
}()
cmd := NewBrowseCmd()
cmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "")
cmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "")
cmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("browse %v: %v", args, err)
}
return buf.String()
}
// runBrowseFmt runs `browse <resource>` with a specific --format value.
func runBrowseFmt(t *testing.T, format, resource string) string {
t.Helper()
return runBrowse(t, "--owner", "o", "--repo", "r", "--format", format, "--no-open", resource)
}
func runBrowseNoOpen(t *testing.T, resource string, noOpen bool) string {
t.Helper()
args := []string{"--owner", "o", "--repo", "r"}
if resource != "" {
args = append(args, resource)
}
if noOpen {
args = append(args, "--no-open")
}
return runBrowse(t, args...)
}

View File

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

View File

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

Binary file not shown.

View File

@ -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:<name>=<value>"
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{},

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

@ -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.json24 小时有效)")

View File

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

View File

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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 <remote> <head_branch>
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
}

View File

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

View File

@ -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, `<!DOCTYPE html><html><body>Login</body></html>`, 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
}