diff --git a/README.md b/README.md index e5e4318..7d3cadc 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,11 @@ gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --ref master gitlink-cli repo +tree --owner Gitlink --repo forgeplus --path src --ref main +# Search topics, then attach/detach one on a repository +gitlink-cli repo +topics -k golang +gitlink-cli repo +topic-add --owner Gitlink --repo forgeplus -n golang +gitlink-cli repo +topic-remove --owner Gitlink --repo forgeplus -t 627 + # Show language breakdown gitlink-cli repo +languages --owner Gitlink --repo forgeplus diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..73f942e 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -87,6 +87,9 @@ "cmd.repo.info.short": "Show repository details", "cmd.repo.list.short": "List repositories for a user or organization", "cmd.repo.short": "Repository operations", + "cmd.repo.topic_add.short": "Attach a topic to the repository", + "cmd.repo.topic_remove.short": "Detach a topic from the repository", + "cmd.repo.topics.short": "List project topics (searchable)", "cmd.repo.tree.short": "List repository files and directories", "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", "cmd.root.short": "GitLink CLI - command-line tool for GitLink", @@ -212,6 +215,10 @@ "flag.repo.description": "Repository description", "flag.repo.name": "Repository name", "flag.repo.private": "Make repository private (true/false)", + "flag.repo.project_id": "GitLink project ID. If omitted, resolved from --owner/--repo.", + "flag.repo.topic.id": "Topic ID from repo +topics", + "flag.repo.topic.name": "Topic name", + "flag.repo.topics.keyword": "Filter topics by name keyword", "flag.repo.tree.path": "Directory path to list (default: repository root)", "flag.repo.tree.ref": "Branch, tag, or commit ref", "flag.search.keyword": "Search keyword", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..b8db8fd 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -87,6 +87,9 @@ "cmd.repo.info.short": "显示仓库详情", "cmd.repo.list.short": "列出用户或组织的仓库", "cmd.repo.short": "仓库操作", + "cmd.repo.topic_add.short": "为仓库添加主题标签", + "cmd.repo.topic_remove.short": "移除仓库的主题标签", + "cmd.repo.topics.short": "列出项目主题标签(可搜索)", "cmd.repo.tree.short": "列出仓库文件和目录", "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", "cmd.root.short": "GitLink CLI - GitLink 命令行工具", @@ -212,6 +215,10 @@ "flag.repo.description": "仓库描述", "flag.repo.name": "仓库名称", "flag.repo.private": "设为私有仓库(true/false)", + "flag.repo.project_id": "GitLink 项目 ID;省略时自动从 --owner/--repo 解析", + "flag.repo.topic.id": "主题 ID(来自 repo +topics)", + "flag.repo.topic.name": "主题名称", + "flag.repo.topics.keyword": "按名称关键字过滤主题", "flag.repo.tree.path": "要列出的目录路径(默认:仓库根目录)", "flag.repo.tree.ref": "分支、标签或提交引用", "flag.search.keyword": "搜索关键词", diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 06774a6..ce53461 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -108,6 +108,89 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "topics", + Description: tr.T("cmd.repo.topics.short"), + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: tr.T("flag.repo.topics.keyword")}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topic-add", + Description: tr.T("cmd.repo.topic_add.short"), + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: tr.T("flag.repo.topic.name"), Required: true}, + {Name: "project-id", Usage: tr.T("flag.repo.project_id")}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + payload := map[string]interface{}{ + "name": name, + "project_id": projectID, + } + env, err := ctx.CallAPI("POST", "/v1/project_topics", payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "topic-remove", + Description: tr.T("cmd.repo.topic_remove.short"), + Flags: []common.Flag{ + {Name: "topic-id", Short: "t", Usage: tr.T("flag.repo.topic.id"), Required: true}, + {Name: "project-id", Usage: tr.T("flag.repo.project_id")}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + topicID, err := ctx.RequireArg("topic-id") + if err != nil { + return err + } + if _, err := strconv.Atoi(topicID); err != nil { + return fmt.Errorf("--topic-id must be an integer, got %q", topicID) + } + projectID, err := resolveRepoProjectID(ctx) + if err != nil { + return err + } + q := url.Values{} + q.Set("project_id", projectID) + env, err := ctx.CallAPIWithQuery("DELETE", "/v1/project_topics/"+topicID, q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "languages", Description: "Show repository language statistics", diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 5c5f34b..d5e4ff2 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -41,6 +42,15 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } +func decodeJSON(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("decode request body: %v", err) + } + return payload +} + func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { t.Helper() w.Header().Set("Content-Type", "application/json") @@ -634,3 +644,59 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) } } + +func TestRepoTopicsListsWithKeyword(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/project_topics.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query = r.URL.RawQuery + writeJSON(t, w, map[string]interface{}{"total_count": 1, "project_topics": []interface{}{}}) + })) + defer server.Close() + + err := runShortcut(t, server, "topics", map[string]string{"page": "1", "limit": "20", "keyword": "go"}) + if err != nil { + t.Fatalf("topics failed: %v", err) + } + if !strings.Contains(query, "keyword=go") { + t.Fatalf("expected keyword in query, got %q", query) + } +} + +func TestRepoTopicAddResolvesProjectID(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) + case r.Method == "POST" && r.URL.Path == "/v1/project_topics.json": + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": float64(0)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runShortcut(t, server, "topic-add", map[string]string{"name": "golang"}) + if err != nil { + t.Fatalf("topic-add failed: %v", err) + } + if payload["name"] != "golang" || payload["project_id"] != "42" { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestRepoTopicRemoveRejectsNonIntegerID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runShortcut(t, server, "topic-remove", map[string]string{"topic-id": "abc"}) + if err == nil { + t.Fatal("expected error for non-integer --topic-id") + } +}