diff --git a/doc/changes/star-shortcut.md b/doc/changes/star-shortcut.md new file mode 100644 index 0000000..a89b620 --- /dev/null +++ b/doc/changes/star-shortcut.md @@ -0,0 +1,44 @@ +# Star shortcut + +新增 `star` Shortcut 组,封装 GitLink 仓库点赞(收藏)相关 OpenAPI 操作,支持给仓库点赞、取消点赞以及查看仓库的点赞用户列表: + +- `star +star` — 点赞(like)仓库 +- `star +unstar` — 取消点赞(unlike)仓库 +- `star +stars` — 列出仓库的点赞用户(stargazers) + +## 实现要点 + +- `+star` / `+unstar` 需要先把 owner/repo 解析为项目 ID:先调用 `GET /{owner}/{repo}`(`ctx.RepoPath()`)取项目信息,再从返回 JSON 的 `id` / `project_id` / `repo_id` 字段中提取数字 ID。 + - 点赞:`POST /projects/{id}/praise_tread/like` + - 取消点赞:`DELETE /projects/{id}/praise_tread/unlike` +- `+stars` 直接走仓库级路径,无需项目 ID:`GET /{owner}/{repo}/stargazers`,返回 `count` 与 `users` 列表。 +- `+stars` 的 `--owner` / `-o` 和 `--repo` / `-r` 均为必填 flag。 +- 所有命令复用 `common.RuntimeContext` 的 `ResolveOwnerRepo`、`CallAPI`、`Output`,输出格式遵循全局 `--format`(默认 json)。 + +## Examples + +```bash +# 给仓库点赞 +gitlink-cli star +star --owner Gitlink --repo forgeplus + +# 取消点赞 +gitlink-cli star +unstar --owner Gitlink --repo forgeplus + +# 查看仓库的点赞用户 +gitlink-cli star +stars --owner Gitlink --repo forgeplus + +# 指定输出格式 +gitlink-cli star +stars --owner Gitlink --repo forgeplus --format yaml +``` + +## Tests + +```bash +GOPROXY=https://goproxy.cn,direct go test ./shortcuts/star/... +go vet ./shortcuts/star/... +go run . star +star --help +go run . star +unstar --help +go run . star +stars --help +``` + +测试通过 `httptest.Server` 模拟后端,校验 `+star` / `+unstar` 会先请求 `GET /{owner}/{repo}.json` 再发出对应的 `praise_tread/like.json` / `unlike.json` 请求,`+stars` 则直接命中 `/{owner}/{repo}/stargazers.json`。 diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..db31e4b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -23,6 +23,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" + "github.com/gitlink-org/gitlink-cli/shortcuts/star" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" @@ -50,6 +51,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), + "star": star.Shortcuts(), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), "dataset": dataset.Shortcuts(tr), @@ -75,6 +77,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), + "star": "Star (favorite) repository operations", "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", "dataset": tr.T("cmd.dataset.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..79db10c 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "profile", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "dataset", "health", "ignore", "wiki", + "dataset", "health", "ignore", "wiki", "star", } groupSet := map[string]bool{} diff --git a/shortcuts/star/star.go b/shortcuts/star/star.go new file mode 100644 index 0000000..c9a0cac --- /dev/null +++ b/shortcuts/star/star.go @@ -0,0 +1,85 @@ +package star + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "star", + Description: "Star (like) a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", + fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "unstar", + Description: "Unstar (unlike) a repository", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", + fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "stars", + Description: "List stargazers of a repository", + Flags: []common.Flag{ + {Name: "owner", Short: "o", Usage: "Repository owner", Required: true}, + {Name: "repo", Short: "r", Usage: "Repository name", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + owner, _ := ctx.RequireArg("owner") + repo, _ := ctx.RequireArg("repo") + env, err := ctx.CallAPI("GET", + fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func resolveProjectID(ctx *common.RuntimeContext) (int64, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("failed to get project info: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected project info response") + } + for _, key := range []string{"id", "project_id", "repo_id"} { + if id, ok := data[key].(float64); ok { + return int64(id), nil + } + } + return 0, fmt.Errorf("cannot find project id in response") +} diff --git a/shortcuts/star/star_test.go b/shortcuts/star/star_test.go new file mode 100644 index 0000000..88718e1 --- /dev/null +++ b/shortcuts/star/star_test.go @@ -0,0 +1,101 @@ +package star + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestStar(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(100)}) + case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json": + writeJSON(t, w, map[string]interface{}{}) + default: + t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil { + t.Fatalf("star failed: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } +} + +func TestUnstar(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo.json": + writeJSON(t, w, map[string]interface{}{"id": float64(100)}) + case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json": + writeJSON(t, w, map[string]interface{}{}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil { + t.Fatalf("unstar failed: %v", err) + } +} + +func TestStars(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "count": 1, + "users": []map[string]interface{}{{"login": "alice"}}, + }) + })) + defer server.Close() + + err := runStarShortcut(t, server, "stars", map[string]string{ + "owner": "owner", "repo": "repo", + }) + if err != nil { + t.Fatalf("stars failed: %v", err) + } +} + +func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findStarShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", Repo: "repo", Format: "json", Args: args, + } + return shortcut.Run(ctx) +} + +func findStarShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(payload) +}