Merge pull request #379: feat(repo): manage project topics

# Conflicts:
#	shortcuts/repo/repo.go
#	shortcuts/repo/repo_test.go
This commit is contained in:
wbtiger 2026-07-14 21:57:49 +08:00
commit 05af86b431
5 changed files with 112 additions and 137 deletions

View File

@ -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

View File

@ -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",

View File

@ -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": "搜索关键词",

View File

@ -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

View File

@ -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")
}
}