diff --git a/README.md b/README.md index 7ec2b2f..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, manage settings, topics, transfer | +| 📦 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 | @@ -182,13 +182,6 @@ export GITLINK_TOKEN="your-private-token" gitlink-cli user +me ``` -If the CLI cannot detect authentication, config, or repository context correctly, run: - -```bash -gitlink-cli doctor -gitlink-cli doctor --skip-network --format json -``` - ## Usage Examples ### Repository Operations @@ -200,27 +193,37 @@ gitlink-cli repo +list # View repository info gitlink-cli repo +info --owner Gitlink --repo forgeplus -# View repository metadata and settings -gitlink-cli repo +detail --owner Gitlink --repo forgeplus -gitlink-cli repo +settings --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" # Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus - -# Update navigation units and manage topics, previewing writes first -gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run -gitlink-cli repo +topics --keyword go -gitlink-cli repo +topic-add --project-id 17 --name go --dry-run - -# Transfer workflow helpers -gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus -gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run ``` ### Webhook Management @@ -268,9 +271,6 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope # List issues gitlink-cli issue +list --owner Gitlink --repo forgeplus -# Search and sort open issues -gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword login --sort-by issues.updated_on --sort-direction desc - # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." @@ -341,9 +341,6 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 # List PRs gitlink-cli pr +list --owner Gitlink --repo forgeplus -# Search merged PRs by keyword -gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword release --sort-by updated_at --sort-direction desc - # Create a PR (same-repo branch) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master @@ -571,18 +568,8 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s # With query parameters gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' - -# Batch plan with template variables and dry-run preview -gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus --dry-run -gitlink-cli api --batch-file plan.json --continue-on-error ``` -### Environment Diagnostics - -`doctor` checks the local config file, config values, stored token or `GITLINK_TOKEN`, -repository context detection, and authenticated API connectivity. Use `--skip-network` -when running in CI or an offline environment. - ## Global Parameters | Parameter | Description | Example | @@ -623,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, settings, topics, transfer, 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 | @@ -749,7 +736,7 @@ You can also download the binary manually from the release page or build from so ### Q: Where are credentials stored on Windows? -gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage under the gitlink-cli config directory (`$GITLINK_CONFIG_DIR/credentials` when set, otherwise `~/.config/gitlink-cli/credentials`). +gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`). ### Q: Where can I find the full API reference? diff --git a/README.zh-CN.md b/README.zh-CN.md index 102ad50..ef50a17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -79,7 +79,7 @@ | 分类 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,管理设置、Topics、迁移 | +| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息、洞察数据和互动状态 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔖 标签 | 创建、列出、更新、删除 Issue 标签 | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | @@ -193,13 +193,6 @@ export GITLINK_TOKEN="your-private-token" gitlink-cli user +me ``` -如果 CLI 无法正确识别认证、配置或仓库上下文,可以运行: - -```bash -gitlink-cli doctor -gitlink-cli doctor --skip-network --format json -``` - ## 使用示例 ### 仓库操作 @@ -211,27 +204,37 @@ gitlink-cli repo +list # 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus -# 查看仓库详情和设置 -gitlink-cli repo +detail --owner Gitlink --repo forgeplus -gitlink-cli repo +settings --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 "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus - -# 更新导航和管理 Topics,写入前先 dry-run -gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run -gitlink-cli repo +topics --keyword go -gitlink-cli repo +topic-add --project-id 17 --name go --dry-run - -# 仓库迁移辅助 -gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus -gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run ``` ### Webhook 管理 @@ -279,9 +282,6 @@ gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role develope # 列出 Issue gitlink-cli issue +list --owner Gitlink --repo forgeplus -# 搜索并按更新时间倒序列出开启中的 Issue -gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --keyword 登录 --sort-by issues.updated_on --sort-direction desc - # 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." @@ -351,9 +351,6 @@ gitlink-cli label +delete --owner Gitlink --repo forgeplus -i 42 # 列出 PR gitlink-cli pr +list --owner Gitlink --repo forgeplus -# 按关键字搜索已合并 PR -gitlink-cli pr +list --owner Gitlink --repo forgeplus --state merged --keyword release --sort-by updated_at --sort-direction desc - # 创建 PR(同仓库分支) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master @@ -450,18 +447,8 @@ Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-s # 带查询参数 gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' - -# 使用模板变量预览批处理计划 -gitlink-cli api --batch-file plan.json --var owner=Gitlink --var repo=forgeplus --dry-run -gitlink-cli api --batch-file plan.json --continue-on-error ``` -### 环境诊断 - -`doctor` 会检查本地配置文件、配置项、已保存 Token 或 `GITLINK_TOKEN`、 -仓库上下文识别和认证 API 连通性。在 CI 或离线环境中可以使用 `--skip-network` -跳过远端访问。 - ## 全局参数 | 参数 | 说明 | 示例 | @@ -502,7 +489,7 @@ git push gitlink | Skill | 说明 | |-------|------| | `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | -| `gitlink-repo` | 仓库操作(创建、设置、Topics、迁移、Fork 等) | +| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork、洞察数据等) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | | `gitlink-member` | 仓库成员与邀请链接管理 | @@ -624,7 +611,7 @@ npm install -g @gitlink-ai/cli ### Q: Windows 上凭证存储在哪里? -gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到配置目录下的文件存储;设置了 `GITLINK_CONFIG_DIR` 时路径为 `$GITLINK_CONFIG_DIR/credentials`,否则为 `~/.config/gitlink-cli/credentials`。 +gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。 ### Q: 如何查看完整的 API 参考? diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index e232fbe..a8c8ce4 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -11,7 +11,7 @@ func TestRegisterAll(t *testing.T) { RegisterAll(root) expectedGroups := []string{ - "repo", "issue", "label", "pr", "release", "branch", + "repo", "issue", "label", "license", "pr", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "health", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index dea2dd2..09c091c 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -82,257 +82,78 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "detail", - Description: "Show repository detail metadata", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/detail", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, + Name: "languages", + Description: "Show repository language statistics", + Run: runLanguages, }, { - Name: "simple", - Description: "Show simplified repository metadata", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/simple", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, + Name: "contributors", + Description: "List repository contributors", + Run: runContributors, }, { - Name: "settings", - Description: "Show repository settings metadata", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/edit", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "units", - Description: "Show repository navigation units", - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/project_units", nil) - if err != nil { - return err - } - return ctx.Output(env) - }, - }, - { - Name: "units-update", - Description: "Update repository navigation units", + Name: "contributor-stats", + Description: "List contributor statistics with code line counts", Flags: []common.Flag{ - {Name: "units", Short: "u", Usage: "Comma-separated units, for example: code,issues,pulls,wiki", Required: true}, - {Name: "dry-run", Usage: "Preview the update request without changing repository units", Bool: true, Default: "false"}, - }, - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - units, err := parseCSVArg(ctx.Arg("units"), "--units") - if err != nil { - return err - } - payload := map[string]interface{}{"unit_types": units} - path := ctx.RepoPath() + "/project_units" - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "dry_run": true, - "action": "update_repository_units", - "method": "POST", - "path": path, - "payload": payload, - }) - } - env, err := ctx.CallAPI("POST", path, payload) - if err != nil { - return err - } - return ctx.Output(env) + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "pass-year", Usage: "Number of past years to include"}, }, + Run: runContributorStats, }, { - Name: "topics", - Description: "List project topic labels", + Name: "code-stats", + Description: "Show repository code statistics", Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Topic keyword filter"}, + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, }, + Run: runCodeStats, + }, + { + Name: "watchers", + Description: "List repository watchers", + Flags: communityListFlags(), Run: func(ctx *common.RuntimeContext) error { - q := url.Values{} - setQueryIfPresent(q, ctx, "keyword", "keyword") - env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q) - if err != nil { - return err - } - return ctx.Output(env) + return runCommunityList(ctx, "watchers") }, }, { - Name: "topic-add", - Description: "Add a topic label to a repository", - Flags: []common.Flag{ - {Name: "project-id", Usage: "Repository project ID", Required: true}, - {Name: "name", Short: "n", Usage: "Topic name", Required: true}, - {Name: "dry-run", Usage: "Preview the create request without changing topics", Bool: true, Default: "false"}, - }, + Name: "stargazers", + Description: "List repository stargazers", + Flags: communityListFlags(), Run: func(ctx *common.RuntimeContext) error { - projectID, err := requiredPositiveIntArg(ctx, "project-id") - if err != nil { - return err - } - name, err := ctx.RequireArg("name") - if err != nil { - return err - } - payload := map[string]interface{}{ - "project_id": projectID, - "name": name, - } - path := "/v1/project_topics" - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "add_repository_topic", - "method": "POST", - "path": path, - "payload": payload, - }) - } - env, err := ctx.CallAPI("POST", path, payload) - if err != nil { - return err - } - return ctx.Output(env) + return runCommunityList(ctx, "stargazers") }, }, { - Name: "topic-delete", - Description: "Remove a topic label from a repository", - Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Project topic ID", Required: true}, - {Name: "project-id", Usage: "Repository project ID", Required: true}, - {Name: "dry-run", Usage: "Preview the delete request without changing topics", Bool: true, Default: "false"}, - }, + Name: "follow", + Description: "Follow a repository", + Flags: repoInteractionFlags(), Run: func(ctx *common.RuntimeContext) error { - id, err := ctx.RequireArg("id") - if err != nil { - return err - } - projectID, err := ctx.RequireArg("project-id") - if err != nil { - return err - } - path := fmt.Sprintf("/v1/project_topics/%s", url.PathEscape(id)) - q := url.Values{"project_id": []string{projectID}} - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "dry_run": true, - "action": "delete_repository_topic", - "method": "DELETE", - "path": path, - "query": q, - }) - } - env, err := ctx.CallAPIWithQuery("DELETE", path, q) - if err != nil { - return err - } - return ctx.Output(env) + return runRepoFollowAction(ctx, "POST", "/watchers/follow", "follow") }, }, { - Name: "transfer-orgs", - Description: "List organizations available for repository transfer", + Name: "unfollow", + Description: "Unfollow a repository", + Flags: repoInteractionFlags(), Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/applied_transfer_projects/organizations", nil) - if err != nil { - return err - } - return ctx.Output(env) + return runRepoFollowAction(ctx, "DELETE", "/watchers/unfollow", "unfollow") }, }, { - Name: "transfer", - Description: "Transfer a repository to another owner", - Flags: []common.Flag{ - {Name: "owner-name", Usage: "Target owner login or organization name", Required: true}, - {Name: "dry-run", Usage: "Preview the transfer request without changing ownership", Bool: true, Default: "false"}, - }, + Name: "like", + Description: "Like a repository", + Flags: repoInteractionFlags(), Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - ownerName, err := ctx.RequireArg("owner-name") - if err != nil { - return err - } - payload := map[string]interface{}{"owner_name": ownerName} - path := ctx.RepoPath() + "/applied_transfer_projects" - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "dry_run": true, - "action": "transfer_repository", - "method": "POST", - "path": path, - "payload": payload, - }) - } - env, err := ctx.CallAPI("POST", path, payload) - if err != nil { - return err - } - return ctx.Output(env) + return runRepoPraiseAction(ctx, "POST", "like") }, }, { - Name: "transfer-cancel", - Description: "Cancel a pending repository transfer", - Flags: []common.Flag{ - {Name: "dry-run", Usage: "Preview the cancel request without changing transfer state", Bool: true, Default: "false"}, - }, + Name: "unlike", + Description: "Unlike a repository", + Flags: repoInteractionFlags(), Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - path := ctx.RepoPath() + "/applied_transfer_projects/cancel" - if ctx.Arg("dry-run") == "true" { - return ctx.OutputData(map[string]interface{}{ - "repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), - "dry_run": true, - "action": "cancel_repository_transfer", - "method": "POST", - "path": path, - }) - } - env, err := ctx.CallAPI("POST", path, nil) - if err != nil { - return err - } - return ctx.Output(env) + return runRepoPraiseAction(ctx, "DELETE", "unlike") }, }, { @@ -415,41 +236,240 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { return i18n.Default() } -func parseCSVArg(value, flagName string) ([]string, error) { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, part := range parts { - item := strings.TrimSpace(part) - if item == "" { - continue - } - if seen[item] { - continue - } - seen[item] = true - result = append(result, item) +func runLanguages(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err } - if len(result) == 0 { - return nil, fmt.Errorf("%s must include at least one value", flagName) - } - return result, nil -} - -func requiredPositiveIntArg(ctx *common.RuntimeContext, flagName string) (int, error) { - value, err := ctx.RequireArg(flagName) + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/languages", nil) if err != nil { - return 0, err + return err } - id, err := strconv.Atoi(value) - if err != nil || id <= 0 { - return 0, fmt.Errorf("--%s must be a positive integer", flagName) - } - return id, nil + return ctx.Output(env) } -func setQueryIfPresent(q url.Values, ctx *common.RuntimeContext, flagName, queryName string) { - if value := ctx.Arg(flagName); value != "" { - q.Set(queryName, value) +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 e3e5edd..f3c1a35 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -2,9 +2,9 @@ package repo import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" - "reflect" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -110,7 +110,7 @@ func TestRepoListWithCategory(t *testing.T) { } } -// --- info/read metadata --- +// --- info/readme/insights --- func TestRepoInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -130,123 +130,199 @@ func TestRepoInfo(t *testing.T) { } } -func TestRepoMetadataShortcuts(t *testing.T) { - tests := []struct { - name string - path string - }{ - {name: "detail", path: "/owner/repo/detail.json"}, - {name: "simple", path: "/owner/repo/simple.json"}, - {name: "settings", path: "/owner/repo/edit.json"}, - {name: "units", path: "/owner/repo/project_units.json"}, - {name: "transfer-orgs", path: "/owner/repo/applied_transfer_projects/organizations.json"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "GET", tc.path) - writeJSON(t, w, map[string]interface{}{"status": 0}) - })) - defer server.Close() - - if err := runShortcut(t, server, tc.name, nil); err != nil { - t.Fatalf("%s shortcut failed: %v", tc.name, err) - } +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", }) - } -} - -func TestRepoUnitsUpdatePayload(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "POST", "/owner/repo/project_units.json") - payload = decodeRepoJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0}) })) defer server.Close() - err := runShortcut(t, server, "units-update", map[string]string{"units": "code,issues,code,wiki"}) - if err != nil { - t.Fatalf("units-update shortcut failed: %v", err) - } - assertRepoStringSlice(t, payload["unit_types"], []string{"code", "issues", "wiki"}) -} - -func TestRepoTopics(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "GET", "/v1/project_topics.json") - assertRepoQuery(t, r, "keyword", "go") - writeJSON(t, w, map[string]interface{}{"total_count": 0, "project_topics": []interface{}{}}) - })) - defer server.Close() - - if err := runShortcut(t, server, "topics", map[string]string{"keyword": "go"}); err != nil { - t.Fatalf("topics shortcut failed: %v", err) - } -} - -func TestRepoTopicAddPayload(t *testing.T) { - var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "POST", "/v1/project_topics.json") - payload = decodeRepoJSON(t, r) - writeJSON(t, w, map[string]interface{}{"status": 0}) - })) - defer server.Close() - - err := runShortcut(t, server, "topic-add", map[string]string{ - "project-id": "17", - "name": "go", + err := runShortcut(t, server, "readme", map[string]string{ + "ref": "main", + "path": "docs", }) if err != nil { - t.Fatalf("topic-add shortcut failed: %v", err) + t.Fatalf("readme shortcut failed: %v", err) } - assertRepoEqual(t, payload["project_id"], float64(17)) - assertRepoEqual(t, payload["name"], "go") } -func TestRepoTopicDelete(t *testing.T) { +func TestRepoLanguagesUsesLanguagesEndpoint(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "DELETE", "/v1/project_topics/8.json") - assertRepoQuery(t, r, "project_id", "17") - writeJSON(t, w, map[string]interface{}{"status": 0}) + assertRequest(t, r, "GET", "/owner/repo/languages.json") + writeJSON(t, w, map[string]interface{}{"Go": "92.4%", "Shell": "7.6%"}) })) defer server.Close() - err := runShortcut(t, server, "topic-delete", map[string]string{ - "id": "8", - "project-id": "17", + 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("topic-delete shortcut failed: %v", err) + t.Fatalf("contributor-stats shortcut failed: %v", err) } } -func TestRepoTransferPayload(t *testing.T) { - var payload map[string]interface{} +func TestRepoCodeStatsUsesRefQuery(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "POST", "/owner/repo/applied_transfer_projects.json") - payload = decodeRepoJSON(t, r) - writeJSON(t, w, map[string]interface{}{"id": 1}) + 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, "transfer", map[string]string{"owner-name": "target-org"}); err != nil { - t.Fatalf("transfer shortcut failed: %v", err) + if err := runShortcut(t, server, "code-stats", map[string]string{"ref": "release/v1"}); err != nil { + t.Fatalf("code-stats shortcut failed: %v", err) } - assertRepoEqual(t, payload["owner_name"], "target-org") } -func TestRepoTransferCancel(t *testing.T) { +func TestRepoWatchersBuildsTimeRangeQuery(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRepoRequest(t, r, "POST", "/owner/repo/applied_transfer_projects/cancel.json") - writeJSON(t, w, map[string]interface{}{"status": 0}) + 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() - if err := runShortcut(t, server, "transfer-cancel", nil); err != nil { - t.Fatalf("transfer-cancel shortcut failed: %v", err) + 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) } } @@ -346,46 +422,52 @@ func TestRepoCreateWithOptions(t *testing.T) { } } -// --- validation/dry-run/error paths --- +// --- validation/error paths --- -func TestRepoDryRunDoesNotCallAPI(t *testing.T) { - dryRunCases := []struct { - name string - args map[string]string - }{ - {name: "units-update", args: map[string]string{"units": "code,issues", "dry-run": "true"}}, - {name: "topic-add", args: map[string]string{"project-id": "17", "name": "go", "dry-run": "true"}}, - {name: "topic-delete", args: map[string]string{"id": "8", "project-id": "17", "dry-run": "true"}}, - {name: "transfer", args: map[string]string{"owner-name": "target-org", "dry-run": "true"}}, - {name: "transfer-cancel", args: map[string]string{"dry-run": "true"}}, - } - - for _, tc := range dryRunCases { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(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 := runShortcut(t, server, tc.name, tc.args); err != nil { - t.Fatalf("%s dry-run failed: %v", tc.name, err) - } - }) - } -} - -func TestRepoTopicAddRejectsInvalidProjectID(t *testing.T) { +func TestRepoInsightValidation(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("invalid project id should not call API, got %s %s", r.Method, r.URL.Path) + t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path) })) defer server.Close() - err := runShortcut(t, server, "topic-add", map[string]string{ - "project-id": "abc", - "name": "go", - }) - if err == nil { - t.Fatal("expected invalid project id to return an error") + 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") + } + }) } } @@ -473,51 +555,16 @@ func TestRepoCreateUserNoLogin(t *testing.T) { } } -func assertRepoRequest(t *testing.T, r *http.Request, method, path string) { +func assertRequest(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) + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } } -func assertRepoQuery(t *testing.T, r *http.Request, key, want string) { +func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Helper() - if got := r.URL.Query().Get(key); got != want { - t.Fatalf("query %s = %q, want %q", key, got, want) - } -} - -func decodeRepoJSON(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 assertRepoEqual(t *testing.T, got interface{}, want interface{}) { - t.Helper() - if !reflect.DeepEqual(got, want) { + if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } - -func assertRepoStringSlice(t *testing.T, got interface{}, want []string) { - t.Helper() - values, ok := got.([]interface{}) - if !ok { - t.Fatalf("got %T, want []interface{}", got) - } - result := make([]string, 0, len(values)) - for _, value := range values { - text, ok := value.(string) - if !ok { - t.Fatalf("got value %v (%T), want string", value, value) - } - result = append(result, text) - } - if !reflect.DeepEqual(result, want) { - t.Fatalf("got %v, want %v", result, want) - } -} diff --git a/skills/README.md b/skills/README.md index a6e61a7..c7f2a23 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,6 +1,6 @@ # GitLink CLI Skills 指南 -[![GitLink](https://img.shields.io/badge/GitLink-Gitlink%2Fgitlink--cli-green)](https://www.gitlink.org.cn/Gitlink/gitlink-cli) +[![GitLink](https://img.shields.io/badge/GitLink-wbtiger%2Fgitlink--cli-green)](https://www.gitlink.org.cn/wbtiger/gitlink-cli) 欢迎使用 GitLink CLI Skills!本指南帮助你快速上手和充分利用 gitlink-cli 的所有功能。 @@ -134,7 +134,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | -| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +settings`, `repo +topics`, `repo +transfer` | +| **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` | @@ -153,7 +153,6 @@ skills/ | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | | **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | | **gitlink-health** | 开源项目健康度 | 详情见SKILL.md | -| **gitlink-gatekeeper** | Policy-as-Code PR 合并门禁:按 `gatekeeper.yaml` 策略算 0-100 评分卡 + 三态裁决(PASS/REQUEST_CHANGES/COMMENT),默认 dry-run、绝不自动合并 | `pr +view/+files/+diff`, `pr +comment`, `label +create` | --- diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index fd6714e..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、删除仓库,管理设置、Topics、导航和迁移。当用户需要操作 GitLink 仓库时触发。" +description: "仓库管理:创建、查看、Fork、删除仓库,查看 README、语言统计、贡献者、关注者,并执行关注/点赞等互动操作。当用户需要操作或分析 GitLink 仓库时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -22,17 +22,17 @@ metadata: |----------|------|----------| | `repo +list` | 仓库列表 | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | -| `repo +detail` | 仓库详情元数据 | 否(公开项目) | -| `repo +simple` | 仓库简版元数据 | 否(公开项目) | -| `repo +settings` | 仓库设置元数据 | 是 | -| `repo +units` | 仓库导航配置 | 是 | -| `repo +units-update` | 更新仓库导航配置 | 是 | -| `repo +topics` | 项目 Topic 标签列表 | 否 | -| `repo +topic-add` | 添加项目 Topic 标签 | 是 | -| `repo +topic-delete` | 删除项目 Topic 标签 | 是 | -| `repo +transfer-orgs` | 可迁移组织列表 | 是 | -| `repo +transfer` | 发起仓库迁移 | 是 | -| `repo +transfer-cancel` | 取消仓库迁移 | 是 | +| `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` | 删除仓库 | 是 | @@ -50,28 +50,31 @@ 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 "项目描述" # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus -# 查看仓库设置、导航和主题 -gitlink-cli repo +detail --owner Gitlink --repo forgeplus -gitlink-cli repo +settings --owner Gitlink --repo forgeplus -gitlink-cli repo +units --owner Gitlink --repo forgeplus -gitlink-cli repo +topics --keyword go - -# 更新导航和 Topics,写入前先 dry-run -gitlink-cli repo +units-update --owner Gitlink --repo forgeplus --units code,issues,pulls,wiki --dry-run -gitlink-cli repo +topic-add --project-id 17 --name go --dry-run -gitlink-cli repo +topic-delete --project-id 17 --id 8 --dry-run - -# 仓库迁移,写入前先 dry-run -gitlink-cli repo +transfer-orgs --owner Gitlink --repo forgeplus -gitlink-cli repo +transfer --owner Gitlink --repo forgeplus --owner-name target-org --dry-run -gitlink-cli repo +transfer-cancel --owner Gitlink --repo forgeplus --dry-run - # 删除仓库(⚠️ 危险操作) gitlink-cli repo +delete --owner myuser --repo old-project ``` @@ -81,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' @@ -103,6 +97,4 @@ gitlink-cli api GET /:owner/:repo/raw/main/README.md ## 注意事项 - `repo +delete` 是不可逆操作,执行前必须确认用户意图 -- `repo +units-update`、`repo +topic-add`、`repo +topic-delete`、`repo +transfer`、`repo +transfer-cancel` 支持 `--dry-run` -- `repo +topic-add` / `repo +topic-delete` 需要 `project_id`,可先用 `repo +detail` 获取 - 创建仓库默认为公开,使用 `--private true` 创建私有仓库