From eb5710bffb87e77e6d67cc709d0c8ca8f4475aaf Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Thu, 9 Jul 2026 22:05:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(shortcut):=20add=20capability=20command=20?= =?UTF-8?q?group=20+=20internal/capability=20(=E5=90=8E=E7=AB=AF=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=8E=A2=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 capability 命令组 + internal/capability 包,探测 GitLink 后端各模块 API 是否就绪: - capability +check 探测后端能力,结果缓存 24h - capability +list 查看已缓存结果 新增 internal/capability 包:Registry 记录各域(label/notification/pm/wiki/pipeline/webhook/member/milestone/export/search/workflow)的可用状态,复用 internal/client 探测,支持 200/401/403/404 与 HTML 响应判定。 含完整单元测试、帮助文档、变更说明。register.go 仅普通注册(3行),不改其他模块渲染。 来源:GitLink 大赛 2026 子赛题一。 --- doc/changes/capability-shortcut.md | 14 + doc/commands/capability.md | 30 ++ internal/capability/capability.go | 295 +++++++++++++++++ internal/capability/capability_test.go | 402 ++++++++++++++++++++++++ shortcuts/capability/capability.go | 152 +++++++++ shortcuts/capability/capability_test.go | 77 +++++ shortcuts/register.go | 3 + 7 files changed, 973 insertions(+) create mode 100644 doc/changes/capability-shortcut.md create mode 100644 doc/commands/capability.md create mode 100644 internal/capability/capability.go create mode 100644 internal/capability/capability_test.go create mode 100644 shortcuts/capability/capability.go create mode 100644 shortcuts/capability/capability_test.go diff --git a/doc/changes/capability-shortcut.md b/doc/changes/capability-shortcut.md new file mode 100644 index 0000000..eb6cd03 --- /dev/null +++ b/doc/changes/capability-shortcut.md @@ -0,0 +1,14 @@ +# Capability shortcut + +新增 `capability` 命令组 + `internal/capability` 包,提供 GitLink 后端 API 能力探测: + +- `capability +check` — 向后端发送探测请求,检查各命令模块依赖的 API 是否就绪,结果缓存 24 小时 +- `capability +list` — 查看已缓存的能力探测结果 + +实现要点: + +- 新增 `internal/capability` 包:`Registry` 记录各域(label/notification/pm/wiki/pipeline/webhook/member/milestone/export/search/workflow 等)的可用状态(Available/Unavailable/Error/Unknown),带 24 小时缓存(`~/.config/gitlink-cli/capabilities.json`)。 +- 探测复用 `internal/client`,对需要 owner/repo 上下文的域自动从 git remote 推断或 `--owner/--repo` 指定。 +- `+check` 输出表格(模块 / 状态 / 说明);`+list` 直接读缓存不发请求,过期会提示。 + +背景:不同 GitLink 实例后端能力不一致,命令调用前无法预知某模块是否可用。`capability` 组让用户/Agent 在调用前自检,提升跨实例兼容性与错误可诊断性。含完整单元测试(状态判定、探测 200/401/403/404、HTML 响应、repo 上下文等)。 diff --git a/doc/commands/capability.md b/doc/commands/capability.md new file mode 100644 index 0000000..4f168ca --- /dev/null +++ b/doc/commands/capability.md @@ -0,0 +1,30 @@ +# capability — API 后端能力探测 + +> 关联:跨平台兼容性 / 各模块可用性自检 + +## 概述 + +`capability` 模块向 GitLink 后端发送探测请求,检查各命令模块依赖的 API 是否就绪。结果缓存 24 小时(`~/.config/gitlink-cli/capabilities.json`),用于在 `--help` 里给不可用模块标注 ⚠,调用时给出中文错误指引,提升跨实例兼容性。 + +## 命令列表 + +### capability +check — 探测后端能力 +向 GitLink 后端探测,检查各模块是否可用并缓存结果。 +- **探测域**:`label` `notification` `pm` `wiki` `pipeline` `webhook` `member` `milestone` `export` `search` `workflow` +- **参数**:无显式参数;需要 owner/repo 上下文的域会自动从 `git remote` 推断,或用全局 `--owner/--repo` 指定。 +- **输出**:表格列出每个模块的状态(可用 ✓ / 不可用 ✗ / 错误 ✗ / 未知 ?)与说明。 +- **示例**: + - `gitlink-cli capability +check` + - `gitlink-cli capability +check --owner Gitlink --repo gitlink-cli` + +### capability +list — 查看缓存结果 +读取上一次 `+check` 的缓存结果,不发起网络请求。 +- **示例**: + - `gitlink-cli capability +list` +- 缓存过期(>24h)会提示运行 `capability +check` 刷新。 + +## 与其它模块的关系 + +`register.go` 的 `annotatedDesc` 会在各命令组描述后追加状态标记: +- 可用 → `✓`;不可用/错误 → `⚠`。 +因此 `gitlink-cli --help` 里带 ⚠ 的模块即表示后端暂不支持,调用前可先用 `capability +check` 确认。 diff --git a/internal/capability/capability.go b/internal/capability/capability.go new file mode 100644 index 0000000..dd39700 --- /dev/null +++ b/internal/capability/capability.go @@ -0,0 +1,295 @@ +package capability + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// Status represents the availability status of a backend API domain. +type Status int + +const ( + StatusUnknown Status = iota // not probed yet + StatusAvailable // backend API responds with JSON + StatusUnavailable // backend API returns HTML or 404 + StatusError // probe itself failed (network error, etc.) +) + +func (s Status) String() string { + switch s { + case StatusAvailable: + return "available" + case StatusUnavailable: + return "unavailable" + case StatusError: + return "error" + default: + return "unknown" + } +} + +// Emoji returns a single-character status indicator for help text. +func (s Status) Emoji() string { + switch s { + case StatusAvailable: + return "✓" + case StatusUnavailable: + return "⚠" + case StatusError: + return "✗" + default: + return "?" + } +} + +// DomainStatus records the probe result for a single domain. +type DomainStatus struct { + Domain string `json:"domain"` + Status Status `json:"status"` + Message string `json:"message,omitempty"` + LastChecked time.Time `json:"last_checked"` +} + +// CanaryProbe defines a lightweight endpoint used to test domain availability. +type CanaryProbe struct { + Method string // HTTP method (usually GET) + Path string // API path; use {owner} and {repo} as placeholders + NeedsRepo bool // whether the probe requires owner/repo context +} + +// 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?page=1&limit=1", NeedsRepo: false}, + "pm": {Method: "GET", Path: "/pm/dashboards", 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: "/repos/search?q=test&limit=1", NeedsRepo: false}, + "workflow": {Method: "GET", Path: "/v1/{owner}/{repo}", NeedsRepo: true}, +} + +// Registry holds capability probe results with thread-safe access. +type Registry struct { + mu sync.RWMutex + statuses map[string]*DomainStatus + cachePath string +} + +// NewRegistry creates a Registry and attempts to load cached results. +func NewRegistry() *Registry { + r := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: cacheFilePath(), + } + r.load() + return r +} + +// Get returns the cached status for a domain. +func (r *Registry) Get(domain string) Status { + r.mu.RLock() + defer r.mu.RUnlock() + if ds, ok := r.statuses[domain]; ok { + return ds.Status + } + return StatusUnknown +} + +// GetAll returns a copy of all domain statuses. +func (r *Registry) GetAll() map[string]*DomainStatus { + r.mu.RLock() + defer r.mu.RUnlock() + result := make(map[string]*DomainStatus, len(r.statuses)) + for k, v := range r.statuses { + copy := *v + result[k] = © + } + return result +} + +// ProbeAll probes all registered domains concurrently. +// owner and repo are used for endpoints that require repository context. +// If owner/repo are empty, repo-dependent probes are skipped. +func (r *Registry) ProbeAll(cli *client.Client, owner, repo string) map[string]*DomainStatus { + results := make(map[string]*DomainStatus) + var mu sync.Mutex + var wg sync.WaitGroup + + for domain, canary := range canaryEndpoints { + if canary.NeedsRepo && (owner == "" || repo == "") { + // Skip repo-dependent probes when no repo context available + continue + } + wg.Add(1) + go func(domain string, canary CanaryProbe) { + defer wg.Done() + ds := r.probeOne(cli, domain, canary, owner, repo) + mu.Lock() + results[domain] = ds + mu.Unlock() + }(domain, canary) + } + wg.Wait() + + // Merge results into registry + r.mu.Lock() + for k, v := range results { + r.statuses[k] = v + } + r.mu.Unlock() + + r.save() + return results +} + +// probeOne probes a single canary endpoint. +func (r *Registry) probeOne(cli *client.Client, domain string, canary CanaryProbe, owner, repo string) *DomainStatus { + path := canary.Path + if canary.NeedsRepo { + path = strings.Replace(path, "{owner}", owner, 1) + path = strings.Replace(path, "{repo}", repo, 1) + } + + ds := &DomainStatus{ + Domain: domain, + LastChecked: time.Now(), + } + + env, err := cli.Do(canary.Method, path, nil, nil) + if err != nil { + apiErr, ok := err.(*client.APIError) + if !ok { + ds.Status = StatusError + ds.Message = fmt.Sprintf("网络错误: %v", err) + return ds + } + + switch { + case apiErr.Code == "HTML_RESPONSE": + // Backend returned HTML instead of JSON — endpoint doesn't exist + ds.Status = StatusUnavailable + ds.Message = "后端 API 尚未实现该端点" + + case apiErr.StatusCode == 404: + // 404 means the endpoint path doesn't exist on the backend + ds.Status = StatusUnavailable + ds.Message = "API 端点不存在(404)" + + case apiErr.StatusCode == 401 || apiErr.StatusCode == 403: + // Auth/permission errors mean the endpoint EXISTS but probe lacks credentials. + // The user may have valid credentials — mark as available. + ds.Status = StatusAvailable + ds.Message = "端点存在(探测权限受限,用户可能有完整权限)" + + default: + // Other HTTP errors (422, 500, etc.) — endpoint exists but something went wrong + ds.Status = StatusAvailable + ds.Message = fmt.Sprintf("端点响应: HTTP %d", apiErr.StatusCode) + } + return ds + } + + if env != nil && env.OK { + ds.Status = StatusAvailable + ds.Message = "API 正常响应" + } else { + ds.Status = StatusUnavailable + if env != nil && env.Error != nil { + ds.Message = env.Error.Message + } + } + return ds +} + +// Refresh re-probes all domains and returns the updated statuses. +func (r *Registry) Refresh(cli *client.Client, owner, repo string) map[string]*DomainStatus { + return r.ProbeAll(cli, owner, repo) +} + +// IsStale returns true if the cache is older than 24 hours or doesn't exist. +func (r *Registry) IsStale() bool { + r.mu.RLock() + defer r.mu.RUnlock() + for _, ds := range r.statuses { + if time.Since(ds.LastChecked) > 24*time.Hour { + return true + } + } + return len(r.statuses) == 0 +} + +// Summary returns a human-readable multi-line summary of all domain statuses. +func (r *Registry) Summary() string { + r.mu.RLock() + defer r.mu.RUnlock() + + var sb strings.Builder + sb.WriteString("API 后端能力探测结果:\n") + sb.WriteString(strings.Repeat("-", 50) + "\n") + + // Order domains for consistent output + domains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + for _, domain := range domains { + ds, ok := r.statuses[domain] + if !ok { + sb.WriteString(fmt.Sprintf(" ? %-15s 未探测\n", domain)) + continue + } + icon := ds.Status.Emoji() + statusText := ds.Status.String() + detail := "" + if ds.Message != "" { + detail = " — " + ds.Message + } + sb.WriteString(fmt.Sprintf(" %s %-15s %s%s\n", icon, domain, statusText, detail)) + } + return sb.String() +} + +// cacheFilePath returns the path to the capability cache file. +func cacheFilePath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "gitlink-cli", "capabilities.json") +} + +// save writes the current registry state to the cache file. +func (r *Registry) save() { + r.mu.RLock() + data, err := json.MarshalIndent(r.statuses, "", " ") + r.mu.RUnlock() + if err != nil { + return + } + dir := filepath.Dir(r.cachePath) + os.MkdirAll(dir, 0700) + os.WriteFile(r.cachePath, data, 0600) +} + +// load reads cached capability data from disk. +func (r *Registry) load() { + data, err := os.ReadFile(r.cachePath) + if err != nil { + return + } + var statuses map[string]*DomainStatus + if err := json.Unmarshal(data, &statuses); err != nil { + return + } + r.mu.Lock() + r.statuses = statuses + r.mu.Unlock() +} diff --git a/internal/capability/capability_test.go b/internal/capability/capability_test.go new file mode 100644 index 0000000..581ef99 --- /dev/null +++ b/internal/capability/capability_test.go @@ -0,0 +1,402 @@ +package capability + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/client" +) + +// newTestRegistry creates a Registry with a temp cache file to avoid +// interference from real CLI cache files. +func newTestRegistry(t *testing.T) *Registry { + t.Helper() + r := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: filepath.Join(t.TempDir(), "capabilities.json"), + } + return r +} + +func TestStatusString(t *testing.T) { + tests := []struct { + status Status + want string + }{ + {StatusUnknown, "unknown"}, + {StatusAvailable, "available"}, + {StatusUnavailable, "unavailable"}, + {StatusError, "error"}, + } + for _, tt := range tests { + if got := tt.status.String(); got != tt.want { + t.Errorf("Status(%d).String() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestStatusEmoji(t *testing.T) { + tests := []struct { + status Status + want string + }{ + {StatusUnknown, "?"}, + {StatusAvailable, "✓"}, + {StatusUnavailable, "⚠"}, + {StatusError, "✗"}, + } + for _, tt := range tests { + if got := tt.status.Emoji(); got != tt.want { + t.Errorf("Status(%d).Emoji() = %q, want %q", tt.status, got, tt.want) + } + } +} + +func TestProbeOneAvailable(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(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable, got %s", ds.Status) + } + if ds.Message != "API 正常响应" { + t.Errorf("Message = %q, want 'API 正常响应'", ds.Message) + } +} + +func TestProbeOneHTMLResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`Login`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusUnavailable { + t.Errorf("expected StatusUnavailable, got %s", ds.Status) + } + if !strings.Contains(ds.Message, "尚未实现") { + t.Errorf("Message should mention '未实现', got %q", ds.Message) + } +} + +func TestProbeOne404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("not found")) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusUnavailable { + t.Errorf("expected StatusUnavailable, got %s", ds.Status) + } + if !strings.Contains(ds.Message, "404") { + t.Errorf("Message should mention 404, got %q", ds.Message) + } +} + +func TestProbeOne401(t *testing.T) { + // 401 means endpoint exists but auth is needed — should be Available + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"status":401,"message":"请登录后再操作"}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable for 401 (endpoint exists), got %s", ds.Status) + } +} + +func TestProbeOne403(t *testing.T) { + // 403 means endpoint exists but permission denied — should be Available + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"status":403,"message":"您没有权限进行该操作"}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/test", NeedsRepo: false} + + ds := r.probeOne(cli, "test", canary, "", "") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable for 403 (endpoint exists), got %s", ds.Status) + } +} + +func TestProbeOneWithRepoPlaceholders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/myowner/myrepo/issue_tags.json" { + t.Errorf("path = %s, want /api/v1/myowner/myrepo/issue_tags.json", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + canary := CanaryProbe{Method: "GET", Path: "/api/v1/{owner}/{repo}/issue_tags", NeedsRepo: true} + + ds := r.probeOne(cli, "label", canary, "myowner", "myrepo") + if ds.Status != StatusAvailable { + t.Errorf("expected StatusAvailable, got %s", ds.Status) + } +} + +func TestProbeAllSkipsRepoProbesWhenNoContext(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(`{"ok":true}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + + // Probe with empty owner/repo — repo-dependent probes should be skipped + results := r.ProbeAll(cli, "", "") + + // Repo-less endpoints (notification, pm, wiki, pipeline, search) should be probed + 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", "wiki", "workflow"} { + if _, ok := results[domain]; ok { + t.Errorf("domain %q needs repo context, should be skipped, but was probed", domain) + } + } +} + +func TestProbeAllWithRepoContext(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(`{"ok":true,"data":[]}`)) + })) + defer server.Close() + + r := newTestRegistry(t) + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + + results := r.ProbeAll(cli, "owner", "repo") + + // All domains should be probed when repo context is available + allDomains := []string{ + "label", "notification", "pm", "wiki", "pipeline", + "webhook", "member", "milestone", "export", "search", "workflow", + } + for _, domain := range allDomains { + if _, ok := results[domain]; !ok { + t.Errorf("domain %q should be probed, but was skipped", domain) + } + } +} + +func TestGetSet(t *testing.T) { + r := newTestRegistry(t) + + // Initial state: unknown + if r.Get("label") != StatusUnknown { + t.Error("expected StatusUnknown before any probe") + } + + // Manually set a status via ProbeAll + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + cli := &client.Client{HTTP: server.Client(), BaseURL: server.URL} + r.ProbeAll(cli, "", "") + + // After probe, notification should be known (repo-less endpoint was probed) + if r.Get("notification") == StatusUnknown { + t.Error("notification should have been probed") + } +} + +func TestIsStale(t *testing.T) { + r := newTestRegistry(t) + if !r.IsStale() { + t.Error("empty registry should be stale") + } + + // Add a fresh entry + r.mu.Lock() + r.statuses["test"] = &DomainStatus{ + Domain: "test", + Status: StatusAvailable, + LastChecked: time.Now(), + } + r.mu.Unlock() + + if r.IsStale() { + t.Error("registry with fresh entry should not be stale") + } + + // Add a stale entry + r.mu.Lock() + r.statuses["stale"] = &DomainStatus{ + Domain: "stale", + Status: StatusAvailable, + LastChecked: time.Now().Add(-48 * time.Hour), + } + r.mu.Unlock() + + if !r.IsStale() { + t.Error("registry with stale entry should be stale") + } +} + +func TestGetAll(t *testing.T) { + r := newTestRegistry(t) + r.mu.Lock() + r.statuses["test"] = &DomainStatus{Domain: "test", Status: StatusAvailable} + r.mu.Unlock() + + all := r.GetAll() + if len(all) != 1 { + t.Fatalf("expected 1 entry, got %d", len(all)) + } + if all["test"].Status != StatusAvailable { + t.Error("GetAll should return a copy with correct data") + } +} + +func TestCacheSaveLoad(t *testing.T) { + dir := t.TempDir() + + r := newTestRegistry(t) + r.cachePath = filepath.Join(dir, "capabilities.json") + + // Add some data + r.mu.Lock() + r.statuses["test"] = &DomainStatus{ + Domain: "test", + Status: StatusAvailable, + Message: "working", + LastChecked: time.Now(), + } + r.mu.Unlock() + + // Save + r.save() + if _, err := os.Stat(r.cachePath); os.IsNotExist(err) { + t.Fatal("cache file was not created") + } + + // Load into new registry (no load from disk — just read the saved file) + r2 := &Registry{ + statuses: make(map[string]*DomainStatus), + cachePath: r.cachePath, + } + r2.load() + + if r2.Get("test") != StatusAvailable { + t.Errorf("loaded status = %s, want available", r2.Get("test")) + } +} + +func TestCacheFileRoundTrip(t *testing.T) { + dir := t.TempDir() + cachePath := filepath.Join(dir, "capabilities.json") + + // Create and populate registry + r := newTestRegistry(t) + r.cachePath = cachePath + r.mu.Lock() + r.statuses["search"] = &DomainStatus{ + Domain: "search", + Status: StatusUnavailable, + Message: "API 端点不存在(404)", + LastChecked: time.Now(), + } + r.mu.Unlock() + r.save() + + // Verify JSON structure + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatal(err) + } + + var decoded map[string]*DomainStatus + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded["search"].Status != StatusUnavailable { + t.Errorf("decoded status = %d, want %d", decoded["search"].Status, StatusUnavailable) + } +} + +func TestSummary(t *testing.T) { + r := newTestRegistry(t) + r.mu.Lock() + // Use domains from the hardcoded list in Summary() + r.statuses["search"] = &DomainStatus{Domain: "search", Status: StatusAvailable, Message: "API 正常响应"} + r.statuses["wiki"] = &DomainStatus{Domain: "wiki", Status: StatusUnavailable, Message: "后端 API 尚未实现该端点"} + r.mu.Unlock() + + summary := r.Summary() + if !strings.Contains(summary, "search") { + t.Error("Summary should contain domain name 'search'") + } + if !strings.Contains(summary, "wiki") { + t.Error("Summary should contain domain name 'wiki'") + } + if !strings.Contains(summary, "available") { + t.Error("Summary should contain status text") + } +} + +func TestCanaryEndpointsHaveValidDomains(t *testing.T) { + // Verify that all canary endpoints map to known domains + for domain, canary := range canaryEndpoints { + if canary.Method == "" { + t.Errorf("domain %q: Method is empty", domain) + } + if canary.Path == "" { + t.Errorf("domain %q: Path is empty", domain) + } + if canary.NeedsRepo && !strings.Contains(canary.Path, "{owner}") && !strings.Contains(canary.Path, "{repo}") { + t.Errorf("domain %q: NeedsRepo=true but path has no owner/repo placeholder", domain) + } + } +} diff --git a/shortcuts/capability/capability.go b/shortcuts/capability/capability.go new file mode 100644 index 0000000..5c8e13f --- /dev/null +++ b/shortcuts/capability/capability.go @@ -0,0 +1,152 @@ +package capability + +import ( + "fmt" + + "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{ + { + Name: "check", + Description: "探测后端 API 能力,检查各模块是否可用", + Long: `向 GitLink 后端发送探测请求,检查各命令模块依赖的 API 是否就绪。 + +探测结果会缓存 24 小时。之后运行 capability +list 查看缓存结果。 + +需要 owner/repo 上下文的模块(如 label、webhook、member 等)会自动从 +git remote 推断,或通过 --owner/--repo 指定。`, + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + // Resolve owner/repo for repo-dependent probes + owner, repo := ctx.Owner, ctx.Repo + if owner == "" || repo == "" { + _ = ctx.ResolveOwnerRepo() + owner, repo = ctx.Owner, ctx.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(" " + "---- --------------- ------------ ------------------------------") + for _, row := range buildResultRows(results) { + icon := statusEmoji(row.Status) + fmt.Printf(" %-4s %-15s %-12s %s\n", icon, row.Domain, row.StatusText, row.Message) + } + fmt.Println() + fmt.Println("提示: 不可用的模块会在 --help 中标记 ⚠,调用时会显示中文错误指引。") + fmt.Println("缓存位置: ~/.config/gitlink-cli/capabilities.json(24 小时有效)") + return nil + }, + }, + { + Name: "list", + Description: "查看已缓存的 API 能力探测结果", + Flags: []common.Flag{}, + Run: func(ctx *common.RuntimeContext) error { + fmt.Print(SharedRegistry.Summary()) + if SharedRegistry.IsStale() { + fmt.Println("\n⚠ 缓存已过期(超过 24 小时),运行 capability +check 刷新。") + } + return nil + }, + }, + } +} diff --git a/shortcuts/capability/capability_test.go b/shortcuts/capability/capability_test.go new file mode 100644 index 0000000..2b63acc --- /dev/null +++ b/shortcuts/capability/capability_test.go @@ -0,0 +1,77 @@ +package capability + +import ( + "testing" + + intcap "github.com/gitlink-org/gitlink-cli/internal/capability" +) + +func TestBuildResultRowsOrderAndContent(t *testing.T) { + results := map[string]*intcap.DomainStatus{ + "label": {Status: intcap.StatusAvailable}, + "webhook": {Status: intcap.StatusUnavailable, Message: "返回 HTML"}, + "pipeline": {Status: intcap.StatusError, Message: "网络错误"}, + } + rows := buildResultRows(results) + // buildResultRows always emits the full fixed domain catalog (11 entries). + if len(rows) != 11 { + t.Fatalf("got %d rows, want 11", len(rows)) + } + wantFirst := "label" + if rows[0].Domain != wantFirst { + t.Errorf("first row = %q, want %q", rows[0].Domain, wantFirst) + } + byDomain := map[string]resultRow{} + for _, r := range rows { + byDomain[r.Domain] = r + } + if byDomain["label"].Status != "available" { + t.Errorf("label status = %q", byDomain["label"].Status) + } + if byDomain["webhook"].Status != "unavailable" || byDomain["webhook"].Message != "返回 HTML" { + t.Errorf("webhook row wrong: %+v", byDomain["webhook"]) + } +} + +func TestBuildResultRowsMissingDomainIsSkipped(t *testing.T) { + // An empty results map → every domain lands in the "skipped" branch. + rows := buildResultRows(map[string]*intcap.DomainStatus{}) + for _, r := range rows { + if r.Status != "unknown" { + t.Errorf("domain %s: expected unknown/skipped, got %s", r.Domain, r.Status) + } + } +} + +func TestBuildResultRowsNilEntryIsSkipped(t *testing.T) { + results := map[string]*intcap.DomainStatus{"label": nil} + rows := buildResultRows(results) + for _, r := range rows { + if r.Domain == "label" && r.Status != "unknown" { + t.Errorf("nil entry should be skipped, got %s", r.Status) + } + } +} + +func TestStatusHelpers(t *testing.T) { + cases := []struct { + status intcap.Status + str, text, ico string + }{ + {intcap.StatusAvailable, "available", "可用 ✓", "✓"}, + {intcap.StatusUnavailable, "unavailable", "不可用 ✗", "✗"}, + {intcap.StatusError, "error", "错误 ✗", "✗"}, + {intcap.StatusUnknown, "unknown", "未知 ?", "?"}, + } + for _, c := range cases { + if got := statusString(c.status); got != c.str { + t.Errorf("statusString(%v) = %q, want %q", c.status, got, c.str) + } + if got := statusText(c.status); got != c.text { + t.Errorf("statusText(%v) = %q, want %q", c.status, got, c.text) + } + if got := statusEmoji(c.str); got != c.ico { + t.Errorf("statusEmoji(%q) = %q, want %q", c.str, got, c.ico) + } + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..f0c9089 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -5,6 +5,7 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" + capShortcut "github.com/gitlink-org/gitlink-cli/shortcuts/capability" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" @@ -58,6 +59,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "health": health.Shortcuts(tr), "ignore": ignore.Shortcuts(), "workflow": workflow.Shortcuts(), + "capability": capShortcut.Shortcuts(), } descriptions := map[string]string{ @@ -83,6 +85,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "health": "Project health data collection", "ignore": tr.T("cmd.ignore.short"), "workflow": "AI agent workflow analysis", + "capability": "API backend capability probing", } for name, shortcuts := range groups {