feat(user): add account and stats shortcuts
This commit is contained in:
parent
52b7093846
commit
a1de64ace7
22
README.md
22
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) |
|
||||
|
|
|
|||
|
|
@ -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 等) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
Loading…
Reference in New Issue