diff --git a/README.md b/README.md index 797e6c7..4c647fc 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| -| 📦 Repo | List, create, fork, delete repositories, view repo info | +| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions | | 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔖 Label | Create, list, update, delete issue labels | | 🔀 PR | Create, merge, review pull requests, view changed files | @@ -196,6 +196,29 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # Read repository README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# Show language breakdown +gitlink-cli repo +languages --owner Gitlink --repo forgeplus + +# List contributors +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# Show contributor code-line stats for a branch, tag, or commit +gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1 + +# Show repository code stats +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + +# List watchers and stargazers in a time range +gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 +gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 + +# Preview and apply repository interaction actions +gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run +gitlink-cli repo +follow --owner Gitlink --repo forgeplus +gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli repo +like --owner Gitlink --repo forgeplus +gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" @@ -587,7 +610,7 @@ See [skills/README.md](skills/README.md) for details. | Skill | Description | |-------|-------------| | `gitlink-shared` | Authentication, global parameters, safety rules, API notes | -| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) | +| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) | | `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | | `gitlink-member` | Repository member and invite link management | diff --git a/README.zh-CN.md b/README.zh-CN.md index 88c6119..ef50a17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,7 +79,7 @@ | 分类 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | +| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -207,6 +207,29 @@ gitlink-cli repo +info --owner Gitlink --repo forgeplus # 读取仓库 README gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master +# 查看语言占比 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus + +# 列出贡献者 +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# 查看分支、标签或提交的贡献者代码行统计 +gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1 + +# 查看仓库代码统计 +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + +# 按时间范围查看关注者和点赞者 +gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 +gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 + +# 预览并执行仓库互动操作 +gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run +gitlink-cli repo +follow --owner Gitlink --repo forgeplus +gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli repo +like --owner Gitlink --repo forgeplus +gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" @@ -466,7 +489,7 @@ git push gitlink | Skill | 说明 | |-------|------| | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | -| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) | +| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | diff --git a/doc/changes/repo-insight-shortcuts.md b/doc/changes/repo-insight-shortcuts.md new file mode 100644 index 0000000..4c98a50 --- /dev/null +++ b/doc/changes/repo-insight-shortcuts.md @@ -0,0 +1,51 @@ +# Repo Insight Shortcuts + +## Summary + +Adds read-only repository insight shortcuts so maintainers and agents can inspect project health without falling back to Raw API calls. + +## Commands + +| Command | Purpose | +|---------|---------| +| `gitlink-cli repo +languages` | Show repository language statistics | +| `gitlink-cli repo +contributors` | List repository contributors | +| `gitlink-cli repo +contributor-stats` | List contributor statistics with additions and deletions | +| `gitlink-cli repo +code-stats` | Show repository code statistics | +| `gitlink-cli repo +watchers` | List repository watchers | +| `gitlink-cli repo +stargazers` | List repository stargazers | +| `gitlink-cli repo +follow` | Follow a repository | +| `gitlink-cli repo +unfollow` | Unfollow a repository | +| `gitlink-cli repo +like` | Like a repository | +| `gitlink-cli repo +unlike` | Unlike a repository | + +## Validation + +- `repo +contributor-stats --pass-year` must be a positive integer. +- `repo +watchers` and `repo +stargazers` accept optional `--start-at` and `--end-at` Unix timestamps. +- Time range timestamps must be non-negative, and `--start-at` cannot be greater than `--end-at`. +- `repo +follow`, `repo +unfollow`, `repo +like`, and `repo +unlike` accept optional `--project-id`; if omitted, the project ID is resolved from `--owner/--repo`. +- Repository interaction actions support `--dry-run` so callers can preview the resolved project ID and endpoint before changing remote state. + +## Tests + +Unit tests cover endpoint paths, query parameter mapping, optional ref and time-range filters, project ID auto-resolution, dry-run previews, and invalid argument handling before any API request is sent. + +## 中文说明 + +### 变更内容 + +- 新增 `repo +languages`、`repo +contributors`、`repo +contributor-stats`、`repo +code-stats`、`repo +watchers`、`repo +stargazers` 等仓库洞察命令。 +- 新增 `repo +follow`、`repo +unfollow`、`repo +like`、`repo +unlike` 仓库互动命令,并支持 `--project-id` 和 `--dry-run`。 +- `repo +contributor-stats` 和 `repo +code-stats` 使用 v1 API,支持 `--ref` 和 `--pass-year` 参数。 +- `repo +watchers` 和 `repo +stargazers` 支持 `--start-at` / `--end-at` 时间范围,并在请求前校验时间戳。 +- 更新 README、README.zh-CN、`gitlink-repo` Skill 和变更说明,减少仓库分析场景对 Raw API 的依赖。 +- 提交者:王越 + +### 验证 + +- `GOPROXY=https://goproxy.cn,direct go test ./...` +- `go run . repo --help` +- `go run . repo +contributor-stats --help` +- `go run . repo +watchers --help` +- `git diff --check` diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index ea4f6ee..09c091c 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -3,6 +3,8 @@ package repo import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -79,6 +81,81 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "languages", + Description: "Show repository language statistics", + Run: runLanguages, + }, + { + Name: "contributors", + Description: "List repository contributors", + Run: runContributors, + }, + { + Name: "contributor-stats", + Description: "List contributor statistics with code line counts", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "pass-year", Usage: "Number of past years to include"}, + }, + Run: runContributorStats, + }, + { + Name: "code-stats", + Description: "Show repository code statistics", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + }, + Run: runCodeStats, + }, + { + Name: "watchers", + Description: "List repository watchers", + Flags: communityListFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runCommunityList(ctx, "watchers") + }, + }, + { + Name: "stargazers", + Description: "List repository stargazers", + Flags: communityListFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runCommunityList(ctx, "stargazers") + }, + }, + { + Name: "follow", + Description: "Follow a repository", + Flags: repoInteractionFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runRepoFollowAction(ctx, "POST", "/watchers/follow", "follow") + }, + }, + { + Name: "unfollow", + Description: "Unfollow a repository", + Flags: repoInteractionFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runRepoFollowAction(ctx, "DELETE", "/watchers/unfollow", "unfollow") + }, + }, + { + Name: "like", + Description: "Like a repository", + Flags: repoInteractionFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runRepoPraiseAction(ctx, "POST", "like") + }, + }, + { + Name: "unlike", + Description: "Unlike a repository", + Flags: repoInteractionFlags(), + Run: func(ctx *common.RuntimeContext) error { + return runRepoPraiseAction(ctx, "DELETE", "unlike") + }, + }, { Name: "create", Description: tr.T("cmd.repo.create.short"), @@ -158,3 +235,241 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func runLanguages(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runContributors(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/contributors", nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runContributorStats(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + setRepoQueryIfPresent(q, "ref", ctx.Arg("ref")) + if passYear := ctx.Arg("pass-year"); strings.TrimSpace(passYear) != "" { + year, err := parseRepoPositiveInt(passYear, "pass-year") + if err != nil { + return err + } + q.Set("pass_year", strconv.Itoa(year)) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/contributors/stat", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runCodeStats(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + setRepoQueryIfPresent(q, "ref", ctx.Arg("ref")) + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/code_stats", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runCommunityList(ctx *common.RuntimeContext, path string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q, err := communityTimeRangeQuery(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/"+path, q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func communityListFlags() []common.Flag { + return []common.Flag{ + {Name: "start-at", Usage: "Start timestamp"}, + {Name: "end-at", Usage: "End timestamp"}, + } +} + +func repoInteractionFlags() []common.Flag { + return []common.Flag{ + {Name: "project-id", Usage: "GitLink project ID. If omitted, it is resolved from --owner/--repo."}, + {Name: "dry-run", Usage: "Preview the action without changing repository state", Bool: true, Default: "false"}, + } +} + +func runRepoFollowAction(ctx *common.RuntimeContext, method, path, action string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + query := url.Values{} + query.Set("target_type", "project") + query.Set("id", projectID) + if ctx.Arg("dry-run") == "true" { + return repoInteractionDryRun(ctx, method, path, action, projectID, query) + } + env, err := ctx.CallAPIWithQuery(method, path, query) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runRepoPraiseAction(ctx *common.RuntimeContext, method, action string) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + path := fmt.Sprintf("/projects/%s/praise_tread/%s", projectID, action) + if ctx.Arg("dry-run") == "true" { + return repoInteractionDryRun(ctx, method, path, action, projectID, nil) + } + env, err := ctx.CallAPI(method, path, nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func repoInteractionDryRun(ctx *common.RuntimeContext, method, path, action, projectID string, query url.Values) error { + preview := map[string]interface{}{ + "dry_run": true, + "action": action, + "method": method, + "path": path, + "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "project_id": projectID, + } + if len(query) > 0 { + preview["query"] = query.Encode() + } + return ctx.OutputData(preview) +} + +func resolveRepoProjectID(ctx *common.RuntimeContext) (string, error) { + if raw := strings.TrimSpace(ctx.Arg("project-id")); raw != "" { + return normalizeRepoProjectID(raw) + } + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return "", fmt.Errorf("resolve project id: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("resolve project id: unexpected repository response") + } + for _, key := range []string{"id", "project_id"} { + if id := repoProjectIDString(data[key]); id != "" { + return id, nil + } + } + return "", fmt.Errorf("resolve project id: repository response did not include id") +} + +func normalizeRepoProjectID(value string) (string, error) { + value = strings.TrimSpace(value) + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return "", fmt.Errorf("invalid --project-id %q: use a positive numeric project ID", value) + } + return strconv.FormatInt(parsed, 10), nil +} + +func repoProjectIDString(value interface{}) string { + switch v := value.(type) { + case float64: + if v > 0 { + return strconv.FormatInt(int64(v), 10) + } + case int: + if v > 0 { + return strconv.Itoa(v) + } + case int64: + if v > 0 { + return strconv.FormatInt(v, 10) + } + case string: + id, err := normalizeRepoProjectID(v) + if err == nil { + return id + } + } + return "" +} + +func communityTimeRangeQuery(ctx *common.RuntimeContext) (url.Values, error) { + q := url.Values{} + start, hasStart, err := parseOptionalRepoNonNegativeInt(ctx.Arg("start-at"), "start-at") + if err != nil { + return nil, err + } + end, hasEnd, err := parseOptionalRepoNonNegativeInt(ctx.Arg("end-at"), "end-at") + if err != nil { + return nil, err + } + if hasStart { + q.Set("start_at", strconv.Itoa(start)) + } + if hasEnd { + q.Set("end_at", strconv.Itoa(end)) + } + if hasStart && hasEnd && start > end { + return nil, fmt.Errorf("--start-at must be less than or equal to --end-at") + } + return q, nil +} + +func setRepoQueryIfPresent(q url.Values, key, value string) { + if value := strings.TrimSpace(value); value != "" { + q.Set(key, value) + } +} + +func parseOptionalRepoNonNegativeInt(value, name string) (int, bool, error) { + if strings.TrimSpace(value) == "" { + return 0, false, nil + } + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed < 0 { + return 0, false, fmt.Errorf("invalid --%s %q: use a non-negative integer", name, value) + } + return parsed, true, nil +} + +func parseRepoPositiveInt(value, name string) (int, error) { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("invalid --%s %q: use a positive integer", name, value) + } + return parsed, nil +} diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 43ed15a..f3c1a35 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -2,6 +2,7 @@ package repo import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() s := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + 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 s.Run(ctx) } @@ -34,9 +41,20 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, code int, text string) { + t.Helper() + w.WriteHeader(code) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } } // --- list --- @@ -49,7 +67,7 @@ func TestRepoListDefault(t *testing.T) { if r.URL.Path != "/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "total_count": float64(2), "data": []interface{}{map[string]interface{}{"name": "repo1"}}, }) @@ -67,7 +85,7 @@ func TestRepoListForUser(t *testing.T) { if r.URL.Path != "/users/alice/projects.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}}) })) defer server.Close() @@ -82,7 +100,7 @@ func TestRepoListWithCategory(t *testing.T) { if r.URL.Query().Get("category") != "mirror" { t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category")) } - writeJSON(w, map[string]interface{}{"data": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"data": []interface{}{}}) })) defer server.Close() @@ -92,14 +110,14 @@ func TestRepoListWithCategory(t *testing.T) { } } -// --- info --- +// --- info/readme/insights --- func TestRepoInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "name": "repo", "description": "test repo", }) @@ -112,7 +130,203 @@ func TestRepoInfo(t *testing.T) { } } -// --- fork --- +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/readme.json") + assertEqual(t, r.URL.Query().Get("ref"), "main") + assertEqual(t, r.URL.Query().Get("filepath"), "docs") + writeJSON(t, w, map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/languages.json") + writeJSON(t, w, map[string]interface{}{"Go": "92.4%", "Shell": "7.6%"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "languages", nil); err != nil { + t.Fatalf("languages shortcut failed: %v", err) + } +} + +func TestRepoContributorsUsesContributorsEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/contributors.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "list": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "contributors", nil); err != nil { + t.Fatalf("contributors shortcut failed: %v", err) + } +} + +func TestRepoContributorStatsBuildsQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/contributors/stat.json") + assertEqual(t, r.URL.Query().Get("ref"), "main") + assertEqual(t, r.URL.Query().Get("pass_year"), "2") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "contributors": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "contributor-stats", map[string]string{ + "ref": " main ", + "pass-year": "2", + }) + if err != nil { + t.Fatalf("contributor-stats shortcut failed: %v", err) + } +} + +func TestRepoCodeStatsUsesRefQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/code_stats.json") + assertEqual(t, r.URL.Query().Get("ref"), "release/v1") + writeJSON(t, w, map[string]interface{}{"author_count": 1, "commit_count": 3}) + })) + defer server.Close() + + if err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/v1"}); err != nil { + t.Fatalf("code-stats shortcut failed: %v", err) + } +} + +func TestRepoWatchersBuildsTimeRangeQuery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/watchers.json") + assertEqual(t, r.URL.Query().Get("start_at"), "1714521600") + assertEqual(t, r.URL.Query().Get("end_at"), "1717200000") + writeJSON(t, w, map[string]interface{}{"count": 1, "users": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "watchers", map[string]string{ + "start-at": "1714521600", + "end-at": "1717200000", + }) + if err != nil { + t.Fatalf("watchers shortcut failed: %v", err) + } +} + +func TestRepoStargazersUsesStargazersEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/stargazers.json") + writeJSON(t, w, map[string]interface{}{"count": 0, "users": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "stargazers", nil); err != nil { + t.Fatalf("stargazers shortcut failed: %v", err) + } +} + +func TestRepoFollowResolvesProjectID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"id": float64(123)}) + case 2: + assertRequest(t, r, "POST", "/watchers/follow.json") + assertEqual(t, r.URL.Query().Get("target_type"), "project") + assertEqual(t, r.URL.Query().Get("id"), "123") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "follow", nil); err != nil { + t.Fatalf("follow shortcut failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestRepoUnfollowUsesExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/watchers/unfollow.json") + assertEqual(t, r.URL.Query().Get("target_type"), "project") + assertEqual(t, r.URL.Query().Get("id"), "456") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": false}) + })) + defer server.Close() + + if err := runShortcut(t, server, "unfollow", map[string]string{"project-id": "456"}); err != nil { + t.Fatalf("unfollow shortcut failed: %v", err) + } +} + +func TestRepoLikeUsesExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/projects/456/praise_tread/like.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runShortcut(t, server, "like", map[string]string{"project-id": "456"}); err != nil { + t.Fatalf("like shortcut failed: %v", err) + } +} + +func TestRepoUnlikeResolvesStringProjectID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + switch requests { + case 1: + assertRequest(t, r, "GET", "/owner/repo.json") + writeJSON(t, w, map[string]interface{}{"project_id": "789"}) + case 2: + assertRequest(t, r, "DELETE", "/projects/789/praise_tread/unlike.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "unlike", nil); err != nil { + t.Fatalf("unlike shortcut failed: %v", err) + } + assertEqual(t, requests, 2) +} + +func TestRepoInteractionDryRunDoesNotCallAPIWithExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run with explicit project-id should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "like", map[string]string{ + "project-id": "456", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("dry-run shortcut failed: %v", err) + } +} + +// --- fork/delete/create --- func TestRepoFork(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -122,7 +336,7 @@ func TestRepoFork(t *testing.T) { if r.URL.Path != "/owner/repo/forks.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "forked"}) + writeJSON(t, w, map[string]interface{}{"message": "forked"}) })) defer server.Close() @@ -132,8 +346,6 @@ func TestRepoFork(t *testing.T) { } } -// --- delete --- - func TestRepoDelete(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "DELETE" { @@ -142,7 +354,7 @@ func TestRepoDelete(t *testing.T) { if r.URL.Path != "/owner/repo.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"message": "deleted"}) + writeJSON(t, w, map[string]interface{}{"message": "deleted"}) })) defer server.Close() @@ -152,18 +364,16 @@ func TestRepoDelete(t *testing.T) { } } -// --- create --- - func TestRepoCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/users/me.json" && r.Method == "GET": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST": - writeJSON(w, map[string]interface{}{"name": "new-repo"}) + writeJSON(t, w, map[string]interface{}{"name": "new-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -181,13 +391,15 @@ func TestRepoCreateWithOptions(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/users/me.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "creator", "user_id": float64(42), }) case "/creator/my-repo.json": - json.NewDecoder(r.Body).Decode(&body) - writeJSON(w, map[string]interface{}{"name": "my-repo"}) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request body: %v", err) + } + writeJSON(t, w, map[string]interface{}{"name": "my-repo"}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } @@ -210,6 +422,55 @@ func TestRepoCreateWithOptions(t *testing.T) { } } +// --- validation/error paths --- + +func TestRepoInsightValidation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + cases := []struct { + name string + shortcut string + args map[string]string + }{ + { + name: "invalid pass year", + shortcut: "contributor-stats", + args: map[string]string{"pass-year": "0"}, + }, + { + name: "invalid start timestamp", + shortcut: "watchers", + args: map[string]string{"start-at": "abc"}, + }, + { + name: "start after end", + shortcut: "stargazers", + args: map[string]string{"start-at": "20", "end-at": "10"}, + }, + { + name: "invalid project id", + shortcut: "follow", + args: map[string]string{"project-id": "repo"}, + }, + { + name: "negative project id", + shortcut: "unlike", + args: map[string]string{"project-id": "-1"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, tc.shortcut, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + func TestRepoCreateFailsWithoutName(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call should be made") @@ -222,12 +483,9 @@ func TestRepoCreateFailsWithoutName(t *testing.T) { } } -// --- HTTP error paths --- - func TestRepoListHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -239,8 +497,7 @@ func TestRepoListHTTPError(t *testing.T) { func TestRepoInfoHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -252,8 +509,7 @@ func TestRepoInfoHTTPError(t *testing.T) { func TestRepoForkHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -265,8 +521,7 @@ func TestRepoForkHTTPError(t *testing.T) { func TestRepoDeleteHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -278,8 +533,7 @@ func TestRepoDeleteHTTPError(t *testing.T) { func TestRepoCreateGetUserHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") })) defer server.Close() @@ -291,7 +545,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) { func TestRepoCreateUserNoLogin(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"user_id": float64(42)}) + writeJSON(t, w, map[string]interface{}{"user_id": float64(42)}) })) defer server.Close() @@ -300,3 +554,17 @@ func TestRepoCreateUserNoLogin(t *testing.T) { t.Fatal("expected error when user response has no login") } } + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/skills/README.md b/skills/README.md index f7e10ea..c7f2a23 100644 --- a/skills/README.md +++ b/skills/README.md @@ -134,7 +134,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | +| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 9fabb8e..bdd75c9 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-repo version: 1.0.0 -description: "仓库管理:创建、查看、Fork、删除仓库,查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。" +description: "仓库管理:创建、查看、Fork、删除仓库,查看 README、语言统计、贡献者、关注者,并执行关注/点赞等互动操作。当用户需要操作或分析 GitLink 仓库时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,6 +22,17 @@ metadata: |----------|------|----------| | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | +| `repo +readme` | README 内容 | 否(公开项目) | +| `repo +languages` | 仓库语言统计 | 否(公开项目) | +| `repo +contributors` | 仓库贡献者列表 | 否(公开项目) | +| `repo +contributor-stats` | 贡献者代码行统计 | 否(公开项目) | +| `repo +code-stats` | 仓库代码统计 | 否(公开项目) | +| `repo +watchers` | 关注者列表 | 否(公开项目) | +| `repo +stargazers` | 点赞者列表 | 否(公开项目) | +| `repo +follow` | 关注仓库 | 是 | +| `repo +unfollow` | 取消关注仓库 | 是 | +| `repo +like` | 点赞仓库 | 是 | +| `repo +unlike` | 取消点赞仓库 | 是 | | `repo +create` | 创建仓库 | 是 | | `repo +fork` | Fork 仓库 | 是 | | `repo +delete` | 删除仓库 | 是 | @@ -39,6 +50,25 @@ gitlink-cli repo +info # 列出用户的仓库 gitlink-cli repo +list --user zhangsan +# 查看语言占比和贡献者 +gitlink-cli repo +languages --owner Gitlink --repo forgeplus +gitlink-cli repo +contributors --owner Gitlink --repo forgeplus + +# 查看代码统计 +gitlink-cli repo +contributor-stats --owner Gitlink --repo forgeplus --ref master --pass-year 1 +gitlink-cli repo +code-stats --owner Gitlink --repo forgeplus --ref master + +# 查看社区关注数据 +gitlink-cli repo +watchers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 +gitlink-cli repo +stargazers --owner Gitlink --repo forgeplus --start-at 1714521600 --end-at 1717200000 + +# 预览并执行仓库互动操作 +gitlink-cli repo +follow --owner Gitlink --repo forgeplus --dry-run +gitlink-cli repo +follow --owner Gitlink --repo forgeplus +gitlink-cli repo +unfollow --owner Gitlink --repo forgeplus --project-id 123 +gitlink-cli repo +like --owner Gitlink --repo forgeplus +gitlink-cli repo +unlike --owner Gitlink --repo forgeplus --project-id 123 + # 创建仓库 gitlink-cli repo +create --name my-project --description "项目描述" @@ -54,15 +84,6 @@ gitlink-cli repo +delete --owner myuser --repo old-project Shortcuts 未覆盖的仓库操作可用 Raw API: ```bash -# 获取 README -gitlink-cli api GET /:owner/:repo/readme - -# 获取贡献者列表 -gitlink-cli api GET /:owner/:repo/contributors - -# 获取语言统计 -gitlink-cli api GET /:owner/:repo/languages - # 获取提交列表 gitlink-cli api GET /:owner/:repo/commits --query 'page=1&limit=20'