Merge PR #59: feat(repo): add insight shortcuts (languages, contributors, contributor-stats, code-stats, community)

Resolved conflicts in README.md, README.zh-CN.md, repo.go, repo_test.go,
skills/README.md, skills/gitlink-repo/SKILL.md. Updated register_test.go
to include new license group.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tiger 2026-06-08 02:32:19 +08:00
parent ca578a74be
commit ee0d882e1c
7 changed files with 568 additions and 536 deletions

View File

@ -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?

View File

@ -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 参考?

View File

@ -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",

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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` |
---

View File

@ -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` 创建私有仓库