From a1de64ace7a1b128e4a85333b6f54daa55c67c14 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Wed, 27 May 2026 22:53:02 +0800 Subject: [PATCH] feat(user): add account and stats shortcuts --- README.md | 22 +- README.zh-CN.md | 22 +- doc/changes/user-account-stats-shortcuts.md | 36 +++ shortcuts/user/user.go | 168 ++++++++++ shortcuts/user/user_test.go | 338 ++++++++++++++++---- skills/README.md | 2 +- skills/gitlink-user/SKILL.md | 37 ++- 7 files changed, 544 insertions(+), 81 deletions(-) create mode 100644 doc/changes/user-account-stats-shortcuts.md diff --git a/README.md b/README.md index b43dcd1..6feb19e 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 Search | Search repositories, users | -| 👤 User | View user profiles and info | +| 👤 User | View profiles, manage public keys, inspect user statistics | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | @@ -477,6 +477,24 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### User Operations + +```bash +# Current authenticated user and detailed profile +gitlink-cli user +me +gitlink-cli user +current --format json + +# Public key management +gitlink-cli user +keys --page 1 --limit 20 +gitlink-cli user +key-create --title "work laptop" --key-file ~/.ssh/id_rsa.pub --dry-run +gitlink-cli user +key-delete --id 7 --dry-run + +# User statistics +gitlink-cli user +activity --login zhangsan +gitlink-cli user +headmap --login zhangsan --year 2026 +gitlink-cli user +develop --login zhangsan --start-time 1704067200 --end-time 1735689599 +``` + ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -647,7 +665,7 @@ See [skills/README.md](skills/README.md) for details. | `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | | `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) | -| `gitlink-user` | User management (profile info, etc.) | +| `gitlink-user` | User management (profiles, public keys, statistics, etc.) | | `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) | | `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) | | `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..a9d221a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,7 +106,7 @@ | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | | 🔍 搜索 | 搜索仓库、用户 | -| 👤 用户 | 查看用户资料和信息 | +| 👤 用户 | 查看资料、管理 Public Keys、查询用户统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | @@ -455,6 +455,24 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 用户操作 + +```bash +# 当前登录用户和详细资料 +gitlink-cli user +me +gitlink-cli user +current --format json + +# Public Key 管理 +gitlink-cli user +keys --page 1 --limit 20 +gitlink-cli user +key-create --title "work laptop" --key-file ~/.ssh/id_rsa.pub --dry-run +gitlink-cli user +key-delete --id 7 --dry-run + +# 用户统计 +gitlink-cli user +activity --login zhangsan +gitlink-cli user +headmap --login zhangsan --year 2026 +gitlink-cli user +develop --login zhangsan --start-time 1704067200 --end-time 1735689599 +``` + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: @@ -525,7 +543,7 @@ git push gitlink | `gitlink-ci` | CI/CD 操作(构建、日志等) | | `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) | | `gitlink-search` | 搜索功能(仓库、用户等) | -| `gitlink-user` | 用户管理(个人信息等) | +| `gitlink-user` | 用户管理(资料、Public Keys、统计等) | | `gitlink-pm` | 项目管理(Sprint、看板、周报等) | | `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) | diff --git a/doc/changes/user-account-stats-shortcuts.md b/doc/changes/user-account-stats-shortcuts.md new file mode 100644 index 0000000..8bec3e7 --- /dev/null +++ b/doc/changes/user-account-stats-shortcuts.md @@ -0,0 +1,36 @@ +# User Account And Stats Shortcuts + +Submitter: Wang Yue + +This change expands the `user` shortcut group with account metadata, Public Key management, and user statistics OpenAPI coverage. + +## Commands + +- `user +current` +- `user +keys` +- `user +key-create` +- `user +key-delete` +- `user +activity` +- `user +headmap` +- `user +develop` +- `user +role` +- `user +major` + +## API Mapping + +| Shortcut | Method | API path | +|----------|--------|----------| +| `user +current` | GET | `/api/users/get_user_info.json` | +| `user +keys` | GET | `/api/public_keys.json` | +| `user +key-create` | POST | `/api/public_keys.json` | +| `user +key-delete` | DELETE | `/api/public_keys/{id}.json` | +| `user +activity` | GET | `/api/users/{owner}/statistics/activity.json` | +| `user +headmap` | GET | `/api/users/{owner}/headmaps.json` | +| `user +develop` | GET | `/api/users/{owner}/statistics/develop.json` | +| `user +role` | GET | `/api/users/{owner}/statistics/role.json` | +| `user +major` | GET | `/api/users/{owner}/statistics/major.json` | + +## Verification + +- Unit tests cover public key list/create/delete requests, dry-run behavior, current profile lookup, user statistics endpoints, query parameters, and argument validation. +- `user +key-create` supports either inline `--key` content or `--key-file`. diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 563db88..3b1eaf2 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,9 @@ package user import ( "fmt" + "net/url" + "os" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -21,6 +24,17 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "current", + Description: "Show current user profile details", + Run: func(ctx *common.RuntimeContext) error { + env, err := ctx.CallAPI("GET", "/users/get_user_info", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "info", Description: tr.T("cmd.user.info.short"), @@ -39,6 +53,160 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "keys", + Description: "List current user public keys", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + setQueryIfPresent(q, ctx, "page", "page") + setQueryIfPresent(q, ctx, "limit", "limit") + env, err := ctx.CallAPIWithQuery("GET", "/public_keys", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "key-create", + Description: "Create a public key", + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "Public key title", Required: true}, + {Name: "key", Short: "k", Usage: "Public key content"}, + {Name: "key-file", Usage: "Path to a public key file"}, + {Name: "dry-run", Usage: "Preview the create request without adding a key", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + title, err := ctx.RequireArg("title") + if err != nil { + return err + } + key, err := publicKeyFromArgs(ctx) + if err != nil { + return err + } + payload := map[string]interface{}{ + "title": title, + "key": key, + } + path := "/public_keys" + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "create_public_key", + "method": "POST", + "path": path, + "payload": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "key-delete", + Description: "Delete a public key", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Public key ID", Required: true}, + {Name: "dry-run", Usage: "Preview the delete request without removing a key", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + path := fmt.Sprintf("/public_keys/%s", url.PathEscape(id)) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "action": "delete_public_key", + "method": "DELETE", + "path": path, + }) + } + env, err := ctx.CallAPI("DELETE", path, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + userStatsShortcut("activity", "Show user recent activity statistics", "statistics/activity", false, false), + userStatsShortcut("headmap", "Show user contribution heatmap", "headmaps", true, false), + userStatsShortcut("develop", "Show user development ability statistics", "statistics/develop", false, true), + userStatsShortcut("role", "Show user role statistics", "statistics/role", false, true), + userStatsShortcut("major", "Show user major statistics", "statistics/major", false, true), + } +} + +func userStatsShortcut(name, description, endpoint string, withYear, withTimeRange bool) *common.Shortcut { + flags := []common.Flag{ + {Name: "login", Short: "l", Usage: "User login name", Required: true}, + } + if withYear { + flags = append(flags, common.Flag{Name: "year", Usage: "Contribution year"}) + } + if withTimeRange { + flags = append(flags, + common.Flag{Name: "start-time", Usage: "Start timestamp"}, + common.Flag{Name: "end-time", Usage: "End timestamp"}, + ) + } + return &common.Shortcut{ + Name: name, + Description: description, + Flags: flags, + Run: func(ctx *common.RuntimeContext) error { + login, err := ctx.RequireArg("login") + if err != nil { + return err + } + q := url.Values{} + setQueryIfPresent(q, ctx, "year", "year") + setQueryIfPresent(q, ctx, "start-time", "start_time") + setQueryIfPresent(q, ctx, "end-time", "end_time") + path := fmt.Sprintf("/users/%s/%s", url.PathEscape(login), endpoint) + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func publicKeyFromArgs(ctx *common.RuntimeContext) (string, error) { + key := strings.TrimSpace(ctx.Arg("key")) + keyFile := strings.TrimSpace(ctx.Arg("key-file")) + if key != "" && keyFile != "" { + return "", fmt.Errorf("use only one of --key or --key-file") + } + if key == "" && keyFile == "" { + return "", fmt.Errorf("required flag --key or --key-file is missing") + } + if keyFile != "" { + data, err := os.ReadFile(keyFile) + if err != nil { + return "", fmt.Errorf("read --key-file: %w", err) + } + key = strings.TrimSpace(string(data)) + } + if key == "" { + return "", fmt.Errorf("public key content is empty") + } + return key, nil +} + +func setQueryIfPresent(q url.Values, ctx *common.RuntimeContext, flagName, queryName string) { + if value := ctx.Arg(flagName); value != "" { + q.Set(queryName, value) } } diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..927d870 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -2,120 +2,328 @@ package user import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - shortcut := findShortcut(t, name) - ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, - Owner: "owner", - Repo: "repo", - Format: "json", - Args: args, - } - return shortcut.Run(ctx) -} - -func findShortcut(t *testing.T, name string) *common.Shortcut { - t.Helper() - for _, s := range Shortcuts() { - if s.Name == name { - return s - } - } - t.Fatalf("shortcut %q not found", name) - return nil -} - -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) -} - -// --- me --- - func TestUserMe(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/me.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{ + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "GET", "/users/me.json") + writeUserJSON(t, w, map[string]interface{}{ "login": "currentuser", "name": "Current User", "id": float64(1), }) - })) + }) defer server.Close() - err := runShortcut(t, server, "me", nil) - if err != nil { - t.Fatalf("me failed: %v", err) + if err := runUserShortcut(t, server, "me", nil); err != nil { + t.Fatalf("me shortcut failed: %v", err) } } -// --- info --- +func TestUserCurrent(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "GET", "/users/get_user_info.json") + writeUserJSON(t, w, map[string]interface{}{"login": "alice"}) + }) + defer server.Close() + + if err := runUserShortcut(t, server, "current", nil); err != nil { + t.Fatalf("current shortcut failed: %v", err) + } +} func TestUserInfo(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/users/alice.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - writeJSON(w, map[string]interface{}{ + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "GET", "/users/alice.json") + writeUserJSON(t, w, map[string]interface{}{ "login": "alice", "name": "Alice", }) - })) + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) + if err := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}); err != nil { + t.Fatalf("info shortcut failed: %v", err) + } +} + +func TestUserKeys(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "GET", "/public_keys.json") + assertUserQuery(t, r, "page", "2") + assertUserQuery(t, r, "limit", "50") + writeUserJSON(t, w, map[string]interface{}{"total_count": 0, "public_keys": []interface{}{}}) + }) + defer server.Close() + + err := runUserShortcut(t, server, "keys", map[string]string{ + "page": "2", + "limit": "50", + }) if err != nil { - t.Fatalf("info failed: %v", err) + t.Fatalf("keys shortcut failed: %v", err) + } +} + +func TestUserKeyCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "POST", "/public_keys.json") + payload = decodeUserJSON(t, r) + writeUserJSON(t, w, map[string]interface{}{"id": 1}) + }) + defer server.Close() + + err := runUserShortcut(t, server, "key-create", map[string]string{ + "title": "work laptop", + "key": "ssh-rsa AAAA test@example.com", + }) + if err != nil { + t.Fatalf("key-create shortcut failed: %v", err) + } + + assertUserEqual(t, payload["title"], "work laptop") + assertUserEqual(t, payload["key"], "ssh-rsa AAAA test@example.com") +} + +func TestUserKeyDelete(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "DELETE", "/public_keys/7.json") + writeUserJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runUserShortcut(t, server, "key-delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("key-delete shortcut failed: %v", err) + } +} + +func TestUserKeyDryRunDoesNotCallAPI(t *testing.T) { + dryRunCases := []struct { + name string + args map[string]string + }{ + {name: "key-create", args: map[string]string{"title": "work laptop", "key": "ssh-rsa AAAA", "dry-run": "true"}}, + {name: "key-delete", args: map[string]string{"id": "7", "dry-run": "true"}}, + } + + for _, tc := range dryRunCases { + t.Run(tc.name, func(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + if err := runUserShortcut(t, server, tc.name, tc.args); err != nil { + t.Fatalf("%s dry-run failed: %v", tc.name, err) + } + }) + } +} + +func TestUserStatsShortcuts(t *testing.T) { + tests := []struct { + name string + args map[string]string + path string + queryKey string + queryValue string + queryKey2 string + queryValue2 string + }{ + {name: "activity", args: map[string]string{"login": "alice"}, path: "/users/alice/statistics/activity.json"}, + {name: "headmap", args: map[string]string{"login": "alice", "year": "2026"}, path: "/users/alice/headmaps.json", queryKey: "year", queryValue: "2026"}, + {name: "develop", args: map[string]string{"login": "alice", "start-time": "100", "end-time": "200"}, path: "/users/alice/statistics/develop.json", queryKey: "start_time", queryValue: "100", queryKey2: "end_time", queryValue2: "200"}, + {name: "role", args: map[string]string{"login": "alice", "start-time": "100", "end-time": "200"}, path: "/users/alice/statistics/role.json", queryKey: "start_time", queryValue: "100", queryKey2: "end_time", queryValue2: "200"}, + {name: "major", args: map[string]string{"login": "alice", "start-time": "100", "end-time": "200"}, path: "/users/alice/statistics/major.json", queryKey: "start_time", queryValue: "100", queryKey2: "end_time", queryValue2: "200"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertUserRequest(t, r, "GET", tc.path) + if tc.queryKey != "" { + assertUserQuery(t, r, tc.queryKey, tc.queryValue) + } + if tc.queryKey2 != "" { + assertUserQuery(t, r, tc.queryKey2, tc.queryValue2) + } + writeUserJSON(t, w, map[string]interface{}{"status": 0}) + }) + defer server.Close() + + if err := runUserShortcut(t, server, tc.name, tc.args); err != nil { + t.Fatalf("%s shortcut failed: %v", tc.name, err) + } + }) } } func TestUserInfoMissingLogin(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected") - })) + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("missing login should not call API") + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{}) + err := runUserShortcut(t, server, "info", map[string]string{}) if err == nil { t.Fatal("expected error for missing login") } } -// --- HTTP error paths --- - -func TestUserMeHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) +func TestUserKeyCreateRejectsMissingKey(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("missing key should not call API, got %s %s", r.Method, r.URL.Path) + }) defer server.Close() - err := runShortcut(t, server, "me", nil) + err := runUserShortcut(t, server, "key-create", map[string]string{"title": "work laptop"}) + if err == nil { + t.Fatal("expected missing key to return an error") + } +} + +func TestUserMeHTTPError(t *testing.T) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + if _, err := w.Write([]byte("server error")); err != nil { + t.Fatalf("write response: %v", err) + } + }) + defer server.Close() + + err := runUserShortcut(t, server, "me", nil) if err == nil { t.Fatal("expected error for HTTP 500") } } func TestUserInfoHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newUserTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + if _, err := w.Write([]byte("server error")); err != nil { + t.Fatalf("write response: %v", err) + } + }) defer server.Close() - err := runShortcut(t, server, "info", map[string]string{"login": "alice"}) + err := runUserShortcut(t, server, "info", map[string]string{"login": "alice"}) if err == nil { t.Fatal("expected error for HTTP 500") } } + +func TestUserShortcutNames(t *testing.T) { + got := map[string]bool{} + for _, shortcut := range Shortcuts() { + got[shortcut.Name] = true + } + want := []string{"me", "current", "info", "keys", "key-create", "key-delete", "activity", "headmap", "develop", "role", "major"} + for _, name := range want { + if !got[name] { + t.Fatalf("missing shortcut %q in %v", name, got) + } + } + if len(got) != len(want) { + t.Fatalf("shortcut count = %d, want %d: %v", len(got), len(want), got) + } +} + +func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findUserShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findUserShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newUserTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertUserRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func assertUserQuery(t *testing.T, r *http.Request, key, want string) { + t.Helper() + if got := r.URL.Query().Get(key); got != want { + t.Fatalf("query %s = %q, want %q", key, got, want) + } +} + +func decodeUserJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeUserJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertUserEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} + +func ExampleShortcuts() { + for _, shortcut := range Shortcuts() { + fmt.Println(shortcut.Name) + } + // Output: + // me + // current + // info + // keys + // key-create + // key-delete + // activity + // headmap + // develop + // role + // major +} diff --git a/skills/README.md b/skills/README.md index af78bca..487239c 100644 --- a/skills/README.md +++ b/skills/README.md @@ -146,7 +146,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-search** | 搜索功能 | `search +repos`, `search +users` | -| **gitlink-user** | 用户管理 | `user +me`, `user +info` | +| **gitlink-user** | 用户管理 | `user +me`, `user +current`, `user +keys`, `user +activity` | | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..b405aa2 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-user version: 1.0.0 -description: "用户操作:查看当前用户、用户详情。当用户需要查看 GitLink 用户信息时触发。" +description: "用户操作:查看当前用户、用户详情、Public Keys 和用户统计。当用户需要查看或管理 GitLink 用户信息时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -21,27 +21,42 @@ metadata: | Shortcut | 说明 | 需要认证 | |----------|------|----------| | `user +me` | 当前登录用户 | 是 | +| `user +current` | 当前用户详细资料 | 是 | | `user +info` | 查看用户详情 | 否 | +| `user +keys` | 当前用户 Public Keys 列表 | 是 | +| `user +key-create` | 创建 Public Key | 是 | +| `user +key-delete` | 删除 Public Key | 是 | +| `user +activity` | 用户近期活动统计 | 否 | +| `user +headmap` | 用户贡献热力图 | 否 | +| `user +develop` | 用户开发能力统计 | 否 | +| `user +role` | 用户角色统计 | 否 | +| `user +major` | 用户专业定位统计 | 否 | ## 使用示例 ```bash # 查看当前用户 gitlink-cli user +me +gitlink-cli user +current --format json # 查看其他用户 gitlink-cli user +info --login zhangsan -``` -## Raw API 补充 - -```bash -# 用户贡献热力图 -gitlink-cli api GET /users/:user_id/headmaps +# Public Key 管理,写入/删除前先 dry-run +gitlink-cli user +keys --page 1 --limit 20 +gitlink-cli user +key-create --title "work laptop" --key-file ~/.ssh/id_rsa.pub --dry-run +gitlink-cli user +key-delete --id 7 --dry-run # 用户统计 -gitlink-cli api GET /users/:user_id/statistics - -# 用户项目动态 -gitlink-cli api GET /users/:user_id/project_trends +gitlink-cli user +activity --login zhangsan +gitlink-cli user +headmap --login zhangsan --year 2026 +gitlink-cli user +develop --login zhangsan --start-time 1704067200 --end-time 1735689599 +gitlink-cli user +role --login zhangsan --start-time 1704067200 --end-time 1735689599 +gitlink-cli user +major --login zhangsan --start-time 1704067200 --end-time 1735689599 ``` + +## API 注意事项 + +- `user +key-create` 支持 `--key` 直接传入公钥内容,也支持 `--key-file` 从本地公钥文件读取。 +- `user +key-create` 和 `user +key-delete` 支持 `--dry-run`,写入/删除前建议先预览请求。 +- 统计命令使用用户 `login`,不是数字用户 ID。