Merge PR #396: feat(shortcut): 新增 capability 命令组 + internal/capability(后端 A
# Conflicts: # shortcuts/register.go
This commit is contained in:
commit
91edff5d94
|
|
@ -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 上下文等)。
|
||||
|
|
@ -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` 确认。
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -22,7 +23,6 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/profile"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repomirror"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
|
|
@ -37,55 +37,55 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
|||
tr = translators[0]
|
||||
}
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(tr),
|
||||
"issue": issue.Shortcuts(tr),
|
||||
"label": label.Shortcuts(),
|
||||
"license": license.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"pipeline": pipeline.Shortcuts(),
|
||||
"pr": pr.Shortcuts(tr),
|
||||
"profile": profile.Shortcuts(tr),
|
||||
"release": release.Shortcuts(tr),
|
||||
"branch": branch.Shortcuts(tr),
|
||||
"org": org.Shortcuts(tr),
|
||||
"user": user.Shortcuts(tr),
|
||||
"search": search.Shortcuts(tr),
|
||||
"ci": ci.Shortcuts(tr),
|
||||
"compare": compare.Shortcuts(),
|
||||
"dataset": dataset.Shortcuts(tr),
|
||||
"webhook": webhook.Shortcuts(tr),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"health": health.Shortcuts(tr),
|
||||
"ignore": ignore.Shortcuts(),
|
||||
"repo-mirror": repomirror.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
"repo": repo.Shortcuts(tr),
|
||||
"issue": issue.Shortcuts(tr),
|
||||
"label": label.Shortcuts(),
|
||||
"license": license.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"pipeline": pipeline.Shortcuts(),
|
||||
"pr": pr.Shortcuts(tr),
|
||||
"profile": profile.Shortcuts(tr),
|
||||
"release": release.Shortcuts(tr),
|
||||
"branch": branch.Shortcuts(tr),
|
||||
"org": org.Shortcuts(tr),
|
||||
"user": user.Shortcuts(tr),
|
||||
"search": search.Shortcuts(tr),
|
||||
"ci": ci.Shortcuts(tr),
|
||||
"compare": compare.Shortcuts(),
|
||||
"dataset": dataset.Shortcuts(tr),
|
||||
"webhook": webhook.Shortcuts(tr),
|
||||
"wiki": wiki.Shortcuts(),
|
||||
"health": health.Shortcuts(tr),
|
||||
"ignore": ignore.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
"capability": capShortcut.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"pipeline": "Pipeline operations",
|
||||
"pr": tr.T("cmd.pr.short"),
|
||||
"profile": tr.T("cmd.profile.short"),
|
||||
"release": tr.T("cmd.release.short"),
|
||||
"branch": tr.T("cmd.branch.short"),
|
||||
"org": tr.T("cmd.org.short"),
|
||||
"user": tr.T("cmd.user.short"),
|
||||
"search": tr.T("cmd.search.short"),
|
||||
"ci": tr.T("cmd.ci.short"),
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"dataset": tr.T("cmd.dataset.short"),
|
||||
"webhook": tr.T("cmd.webhook.short"),
|
||||
"wiki": "Wiki page management",
|
||||
"health": "Project health data collection",
|
||||
"ignore": tr.T("cmd.ignore.short"),
|
||||
"repo-mirror": "Repository mirror operations",
|
||||
"workflow": "AI agent workflow analysis",
|
||||
"repo": tr.T("cmd.repo.short"),
|
||||
"issue": tr.T("cmd.issue.short"),
|
||||
"label": "Issue label operations",
|
||||
"license": "License operations",
|
||||
"member": "Repository member operations",
|
||||
"milestone": "Milestone operations",
|
||||
"pipeline": "Pipeline operations",
|
||||
"pr": tr.T("cmd.pr.short"),
|
||||
"profile": tr.T("cmd.profile.short"),
|
||||
"release": tr.T("cmd.release.short"),
|
||||
"branch": tr.T("cmd.branch.short"),
|
||||
"org": tr.T("cmd.org.short"),
|
||||
"user": tr.T("cmd.user.short"),
|
||||
"search": tr.T("cmd.search.short"),
|
||||
"ci": tr.T("cmd.ci.short"),
|
||||
"compare": "Compare branches, tags, or commits",
|
||||
"dataset": tr.T("cmd.dataset.short"),
|
||||
"webhook": tr.T("cmd.webhook.short"),
|
||||
"wiki": "Wiki page management",
|
||||
"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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue