Merge pull request 'feat(repo): add settings and topic shortcuts' (#71) from wangyue111/gitlink-cli:feat/repo-settings-shortcuts into master
This commit is contained in:
commit
ca578a74be
17
README.md
17
README.md
|
|
@ -79,7 +79,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|
||||
| Category | Capabilities |
|
||||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||
| 📦 Repo | List, create, fork, delete repositories, 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 |
|
||||
|
|
@ -200,6 +200,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
|
||||
|
||||
|
|
@ -208,6 +212,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
|
||||
|
|
@ -610,7 +623,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 |
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@
|
|||
|
||||
| 分类 | 能力 |
|
||||
|------|------|
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,管理设置、Topics、迁移 |
|
||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
||||
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
|
||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||
|
|
@ -211,6 +211,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
|
||||
|
||||
|
|
@ -219,6 +223,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 管理
|
||||
|
|
@ -489,7 +502,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` | 仓库成员与邀请链接管理 |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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` 创建私有仓库
|
||||
|
|
|
|||
Loading…
Reference in New Issue