feat(shortcut): add capability command group + internal/capability (后端能力探测)

新增 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 子赛题一。
This commit is contained in:
wyxfzgg 2026-07-09 22:05:53 +08:00
parent 9749a4c832
commit eb5710bffb
7 changed files with 973 additions and 0 deletions

View File

@ -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 上下文等)。

View File

@ -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` 确认。

View File

@ -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] = &copy
}
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()
}

View File

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

View File

@ -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 上下文的模块 labelwebhookmember 会自动从
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.json24 小时有效)")
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
},
},
}
}

View File

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

View File

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