feat(shortcut): 新增 star 命令模块,支持仓库收藏 #394
|
|
@ -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`。
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue