diff --git a/README.md b/README.md index 5ade3e5..31c4c00 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,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 2a261ca..49ee36d 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -88,6 +88,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", @@ -213,6 +216,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 ae901ef..9e43372 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -88,6 +88,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 命令行工具", @@ -213,6 +216,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 ad046f0..ce53461 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -1,7 +1,6 @@ package repo import ( - "encoding/base64" "fmt" "net/url" "strconv" @@ -110,29 +109,21 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "git-tree", - Description: tr.T("cmd.repo.git_tree.short"), + Name: "topics", + Description: tr.T("cmd.repo.topics.short"), Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.repo.git.sha"), Required: true}, - {Name: "recursive", Short: "r", Usage: tr.T("flag.repo.git.recursive"), Bool: true}, + {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 { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - sha, err := ctx.RequireArg("sha") - if err != nil { - return err - } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) - if ctx.Arg("recursive") == "true" { - q.Set("recursive", "true") + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) } - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/git/trees/%s", ctx.RepoPath(), url.PathEscape(sha)), q) + env, err := ctx.CallAPIWithQuery("GET", "/v1/project_topics", q) if err != nil { return err } @@ -140,37 +131,62 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "blob", - Description: tr.T("cmd.repo.blob.short"), + Name: "topic-add", + Description: tr.T("cmd.repo.topic_add.short"), Flags: []common.Flag{ - {Name: "sha", Short: "s", Usage: tr.T("flag.repo.git.sha"), Required: true}, - {Name: "decode", Short: "d", Usage: tr.T("flag.repo.git.decode"), Bool: true}, + {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 } - sha, err := ctx.RequireArg("sha") + name, err := ctx.RequireArg("name") if err != nil { return err } - env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1%s/git/blobs/%s", ctx.RepoPath(), url.PathEscape(sha)), nil) + projectID, err := resolveRepoProjectID(ctx) if err != nil { return err } - if ctx.Arg("decode") == "true" { - if data, ok := env.Data.(map[string]interface{}); ok { - if enc, _ := data["encoding"].(string); enc == "base64" { - if content, _ := data["content"].(string); content != "" { - decoded, err := base64.StdEncoding.DecodeString(content) - if err != nil { - return fmt.Errorf("decode blob content: %w", err) - } - fmt.Print(string(decoded)) - return nil - } - } - } + 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) }, @@ -292,14 +308,6 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, - { - Name: "rename", - Description: tr.T("cmd.repo.rename.short"), - Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: tr.T("flag.repo.rename.name"), Required: true}, - }, - Run: runRename, - }, { Name: "fork", Description: tr.T("cmd.repo.fork.short"), @@ -338,31 +346,6 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { return i18n.Default() } -func runRename(ctx *common.RuntimeContext) error { - if _, err := ctx.RequireArg("name"); err != nil { - return err - } - name := strings.TrimSpace(ctx.Arg("name")) - if name == "" { - return fmt.Errorf("invalid --name: repository name must not be empty") - } - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - // The update endpoint requires both the display name and the identifier; the - // identifier is the URL slug, so renaming it also changes the repository's - // remote clone URL, mirroring `gh repo rename`. - body := map[string]interface{}{ - "name": name, - "identifier": name, - } - env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body) - if err != nil { - return err - } - return ctx.Output(env) -} - func runLanguages(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go index 519e174..d5e4ff2 100644 --- a/shortcuts/repo/repo_test.go +++ b/shortcuts/repo/repo_test.go @@ -1,7 +1,6 @@ package repo import ( - "encoding/base64" "encoding/json" "fmt" "net/http" @@ -43,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") @@ -490,58 +498,6 @@ func TestRepoCreateWithOptions(t *testing.T) { } } -// --- rename --- - -func TestRepoRename(t *testing.T) { - cases := []struct { - name string - arg string - wantName string - }{ - {name: "simple name", arg: "new-repo", wantName: "new-repo"}, - {name: "trims surrounding whitespace", arg: " renamed ", wantName: "renamed"}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var body map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertRequest(t, r, "PATCH", "/owner/repo.json") - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatalf("decode request body: %v", err) - } - writeJSON(t, w, map[string]interface{}{ - "id": float64(21), - "name": tc.wantName, - "identifier": tc.wantName, - }) - })) - defer server.Close() - - if err := runShortcut(t, server, "rename", map[string]string{"name": tc.arg}); err != nil { - t.Fatalf("rename failed: %v", err) - } - // Renaming updates both the display name and the URL identifier, so the - // PATCH payload must carry the new name in each field. - assertEqual(t, body["name"], tc.wantName) - assertEqual(t, body["identifier"], tc.wantName) - }) - } -} - -func TestRepoRenameRejectsBlankName(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("blank name should not call API, got: %s %s", r.Method, r.URL.Path) - })) - defer server.Close() - - for _, args := range []map[string]string{nil, {"name": " "}} { - if err := runShortcut(t, server, "rename", args); err == nil { - t.Fatal("expected validation error for missing or blank name") - } - } -} - // --- validation/error paths --- func TestRepoInsightValidation(t *testing.T) { @@ -689,41 +645,58 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { } } -func TestRepoGitTreeBuildsPathAndRecursive(t *testing.T) { +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/owner/repo/git/trees/master.json" { + 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": float64(2), "entries": []interface{}{}}) + writeJSON(t, w, map[string]interface{}{"total_count": 1, "project_topics": []interface{}{}}) })) defer server.Close() - if err := runShortcut(t, server, "git-tree", map[string]string{"sha": "master", "recursive": "true", "page": "1", "limit": "20"}); err != nil { - t.Fatalf("git-tree failed: %v", err) + 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, "recursive=true") { - t.Fatalf("expected recursive=true in query, got %q", query) + if !strings.Contains(query, "keyword=go") { + t.Fatalf("expected keyword in query, got %q", query) } } -func TestRepoBlobDecode(t *testing.T) { +func TestRepoTopicAddResolvesProjectID(t *testing.T) { + var payload map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/git/blobs/abc123.json" { + 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) } - writeJSON(t, w, map[string]interface{}{ - "sha": "abc123", "encoding": "base64", - "content": base64.StdEncoding.EncodeToString([]byte("hello blob")), - }) })) defer server.Close() - if err := runShortcut(t, server, "blob", map[string]string{"sha": "abc123", "decode": "true"}); err != nil { - t.Fatalf("blob failed: %v", err) + err := runShortcut(t, server, "topic-add", map[string]string{"name": "golang"}) + if err != nil { + t.Fatalf("topic-add failed: %v", err) } - if err := runShortcut(t, server, "blob", map[string]string{"sha": "abc123"}); err != nil { - t.Fatalf("blob without decode 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") } }