feat(repo): add settings and topic shortcuts

This commit is contained in:
wangyue789 2026-05-27 23:03:16 +08:00
parent b45241dcda
commit 9bfa59c7e8
7 changed files with 650 additions and 39 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, view repo info |
| 📦 Repo | List, create, fork, delete repositories, manage settings, topics, transfer |
| 🐛 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 |
@ -193,6 +193,10 @@ 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
@ -201,6 +205,15 @@ 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
@ -587,7 +600,7 @@ See [skills/README.md](skills/README.md) for details.
| Skill | Description |
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
| `gitlink-repo` | Repository operations (create, settings, topics, transfer, fork, 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 |

View File

@ -79,7 +79,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 📦 仓库 | 列出、创建、Fork、删除仓库管理设置、Topics、迁移 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
@ -204,6 +204,10 @@ 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
@ -212,6 +216,15 @@ 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 管理
@ -466,7 +479,7 @@ git push gitlink
| Skill | 说明 |
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) |
| `gitlink-repo` | 仓库操作(创建、设置、Topics、迁移、Fork 等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |

View File

@ -0,0 +1,40 @@
# Repository Settings Shortcuts
Submitter: Wang Yue
This change expands repository shortcut coverage for repository metadata, settings, project topics, navigation units, and transfer OpenAPI endpoints.
## Commands
- `repo +detail`
- `repo +simple`
- `repo +settings`
- `repo +units`
- `repo +units-update`
- `repo +topics`
- `repo +topic-add`
- `repo +topic-delete`
- `repo +transfer-orgs`
- `repo +transfer`
- `repo +transfer-cancel`
## API Mapping
| Shortcut | Method | API path |
|----------|--------|----------|
| `repo +detail` | GET | `/api/{owner}/{repo}/detail.json` |
| `repo +simple` | GET | `/api/{owner}/{repo}/simple.json` |
| `repo +settings` | GET | `/api/{owner}/{repo}/edit.json` |
| `repo +units` | GET | `/api/{owner}/{repo}/project_units.json` |
| `repo +units-update` | POST | `/api/{owner}/{repo}/project_units.json` |
| `repo +topics` | GET | `/api/v1/project_topics.json` |
| `repo +topic-add` | POST | `/api/v1/project_topics.json` |
| `repo +topic-delete` | DELETE | `/api/v1/project_topics/{id}.json` |
| `repo +transfer-orgs` | GET | `/api/{owner}/{repo}/applied_transfer_projects/organizations.json` |
| `repo +transfer` | POST | `/api/{owner}/{repo}/applied_transfer_projects.json` |
| `repo +transfer-cancel` | POST | `/api/{owner}/{repo}/applied_transfer_projects/cancel.json` |
## Verification
- Unit tests cover request methods, paths, query parameters, JSON payloads, dry-run behavior, CSV de-duplication, and invalid project ID validation.
- Write and state-changing commands support `--dry-run`.

View File

@ -3,6 +3,8 @@ package repo
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -79,6 +81,260 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
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: "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: "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",
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: "topics",
Description: "List project topic labels",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Topic keyword filter"},
},
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)
},
},
{
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"},
},
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)
},
},
{
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"},
},
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)
},
},
{
Name: "transfer-orgs",
Description: "List organizations available for repository transfer",
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)
},
},
{
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"},
},
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)
},
},
{
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"},
},
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)
},
},
{
Name: "create",
Description: tr.T("cmd.repo.create.short"),
@ -158,3 +414,42 @@ 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)
}
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)
if err != nil {
return 0, 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
}
func setQueryIfPresent(q url.Values, ctx *common.RuntimeContext, flagName, queryName string) {
if value := ctx.Arg(flagName); value != "" {
q.Set(queryName, value)
}
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
t.Helper()
s := findShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return s.Run(ctx)
}
@ -34,9 +41,20 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
func writeJSON(w http.ResponseWriter, v interface{}) {
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write response: %v", err)
}
}
func writeText(t *testing.T, w http.ResponseWriter, code int, text string) {
t.Helper()
w.WriteHeader(code)
if _, err := w.Write([]byte(text)); err != nil {
t.Fatalf("write response: %v", err)
}
}
// --- list ---
@ -49,7 +67,7 @@ func TestRepoListDefault(t *testing.T) {
if r.URL.Path != "/projects.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"total_count": float64(2),
"data": []interface{}{map[string]interface{}{"name": "repo1"}},
})
@ -67,7 +85,7 @@ func TestRepoListForUser(t *testing.T) {
if r.URL.Path != "/users/alice/projects.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}})
writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "data": []interface{}{}})
}))
defer server.Close()
@ -82,7 +100,7 @@ func TestRepoListWithCategory(t *testing.T) {
if r.URL.Query().Get("category") != "mirror" {
t.Fatalf("expected category=mirror, got %s", r.URL.Query().Get("category"))
}
writeJSON(w, map[string]interface{}{"data": []interface{}{}})
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
}))
defer server.Close()
@ -92,14 +110,14 @@ func TestRepoListWithCategory(t *testing.T) {
}
}
// --- info ---
// --- info/read metadata ---
func TestRepoInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/owner/repo.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"description": "test repo",
})
@ -112,7 +130,127 @@ func TestRepoInfo(t *testing.T) {
}
}
// --- fork ---
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 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",
})
if err != nil {
t.Fatalf("topic-add shortcut failed: %v", err)
}
assertRepoEqual(t, payload["project_id"], float64(17))
assertRepoEqual(t, payload["name"], "go")
}
func TestRepoTopicDelete(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})
}))
defer server.Close()
err := runShortcut(t, server, "topic-delete", map[string]string{
"id": "8",
"project-id": "17",
})
if err != nil {
t.Fatalf("topic-delete shortcut failed: %v", err)
}
}
func TestRepoTransferPayload(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/applied_transfer_projects.json")
payload = decodeRepoJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": 1})
}))
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)
}
assertRepoEqual(t, payload["owner_name"], "target-org")
}
func TestRepoTransferCancel(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})
}))
defer server.Close()
if err := runShortcut(t, server, "transfer-cancel", nil); err != nil {
t.Fatalf("transfer-cancel shortcut failed: %v", err)
}
}
// --- fork/delete/create ---
func TestRepoFork(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -122,7 +260,7 @@ func TestRepoFork(t *testing.T) {
if r.URL.Path != "/owner/repo/forks.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "forked"})
writeJSON(t, w, map[string]interface{}{"message": "forked"})
}))
defer server.Close()
@ -132,8 +270,6 @@ func TestRepoFork(t *testing.T) {
}
}
// --- delete ---
func TestRepoDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
@ -142,7 +278,7 @@ func TestRepoDelete(t *testing.T) {
if r.URL.Path != "/owner/repo.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(w, map[string]interface{}{"message": "deleted"})
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
}))
defer server.Close()
@ -152,18 +288,16 @@ func TestRepoDelete(t *testing.T) {
}
}
// --- create ---
func TestRepoCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/users/me.json" && r.Method == "GET":
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "creator",
"user_id": float64(42),
})
case r.URL.Path == "/creator/new-repo.json" && r.Method == "POST":
writeJSON(w, map[string]interface{}{"name": "new-repo"})
writeJSON(t, w, map[string]interface{}{"name": "new-repo"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
@ -181,13 +315,15 @@ func TestRepoCreateWithOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/users/me.json":
writeJSON(w, map[string]interface{}{
writeJSON(t, w, map[string]interface{}{
"login": "creator",
"user_id": float64(42),
})
case "/creator/my-repo.json":
json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, map[string]interface{}{"name": "my-repo"})
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"name": "my-repo"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
@ -210,6 +346,49 @@ func TestRepoCreateWithOptions(t *testing.T) {
}
}
// --- validation/dry-run/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) {
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)
}))
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")
}
}
func TestRepoCreateFailsWithoutName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made")
@ -222,12 +401,9 @@ func TestRepoCreateFailsWithoutName(t *testing.T) {
}
}
// --- HTTP error paths ---
func TestRepoListHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -239,8 +415,7 @@ func TestRepoListHTTPError(t *testing.T) {
func TestRepoInfoHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -252,8 +427,7 @@ func TestRepoInfoHTTPError(t *testing.T) {
func TestRepoForkHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -265,8 +439,7 @@ func TestRepoForkHTTPError(t *testing.T) {
func TestRepoDeleteHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -278,8 +451,7 @@ func TestRepoDeleteHTTPError(t *testing.T) {
func TestRepoCreateGetUserHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
writeText(t, w, http.StatusInternalServerError, "server error")
}))
defer server.Close()
@ -291,7 +463,7 @@ func TestRepoCreateGetUserHTTPError(t *testing.T) {
func TestRepoCreateUserNoLogin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"user_id": float64(42)})
writeJSON(t, w, map[string]interface{}{"user_id": float64(42)})
}))
defer server.Close()
@ -300,3 +472,52 @@ func TestRepoCreateUserNoLogin(t *testing.T) {
t.Fatal("expected error when user response has no login")
}
}
func assertRepoRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func assertRepoQuery(t *testing.T, r *http.Request, key, want string) {
t.Helper()
if got := r.URL.Query().Get(key); got != want {
t.Fatalf("query %s = %q, want %q", key, got, want)
}
}
func 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) {
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

@ -134,7 +134,7 @@ skills/
| Skill | 说明 | 常用命令 |
|-------|------|----------|
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +settings`, `repo +topics`, `repo +transfer` |
| **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` |

View File

@ -1,7 +1,7 @@
---
name: gitlink-repo
version: 1.0.0
description: "仓库管理创建、查看、Fork、删除仓库查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。"
description: "仓库管理创建、查看、Fork、删除仓库管理设置、Topics、导航和迁移。当用户需要操作 GitLink 仓库时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
@ -22,6 +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 +create` | 创建仓库 | 是 |
| `repo +fork` | Fork 仓库 | 是 |
| `repo +delete` | 删除仓库 | 是 |
@ -45,6 +56,22 @@ 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
```
@ -76,4 +103,6 @@ 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` 创建私有仓库