feat(repo): manage project topics (+topics, +topic-add, +topic-remove)
Wraps the v1 project_topics API: - repo +topics: searchable, paginated topic list (-k keyword) - repo +topic-add -n <name>: attach a topic; project_id auto-resolved from --owner/--repo (or passed via --project-id) - repo +topic-remove -t <topic-id>: detach a topic (integer-validated) Production-verified add/remove round-trip on gitlink.org.cn. 3 unit tests, README examples, and bilingual i18n keys. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c09645da62
commit
ca974f111e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "搜索关键词",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue