feat(search): add recommended projects shortcut

Add `search +recommend`, listing the platform's recommended/featured
projects via GET /api/projects/recommend — a discovery endpoint that had
no shortcut coverage. The endpoint returns a bare JSON array; the command
normalizes it into structured data for clean output.

Includes unit tests, bilingual (en-US/zh-CN) help text, README updates,
and a change note. Verified against production gitlink.org.cn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
luwanzhou 2026-06-15 01:42:15 +08:00
parent ef7a2c6ac1
commit e5cb868442
7 changed files with 101 additions and 0 deletions

View File

@ -495,6 +495,9 @@ gitlink-cli search +repos -k "machine learning"
# Search users
gitlink-cli search +users -k "zhangsan"
# List recommended / featured projects
gitlink-cli search +recommend
```
### User Profile

View File

@ -473,6 +473,9 @@ gitlink-cli search +repos -k "machine learning"
# 搜索用户
gitlink-cli search +users -k "zhangsan"
# 列出推荐/精选项目
gitlink-cli search +recommend
```
### 用户画像

View File

@ -0,0 +1,39 @@
# Search: Recommended Projects
## Summary
Adds `search +recommend`, which lists the platform's recommended / featured
projects. This wraps `GET /api/projects/recommend`, a discovery endpoint that
previously had no shortcut coverage.
## Command
| Command | Purpose | Endpoint |
|---------|---------|----------|
| `gitlink-cli search +recommend` | List recommended/featured projects | `GET /projects/recommend` |
Each item includes `id`, `identifier`, `name`, `visits`, `author`
(`name`/`login`/`image_url`) and `category`.
## Behaviour
- Takes no arguments. Honors the global `--format` (json/table/yaml).
- The endpoint returns a bare JSON array; the command normalizes it into
structured data so the output is clean (not an escaped JSON string).
## Tests
Unit tests cover the endpoint path, the bare-array normalization, and HTTP
error handling.
## 中文说明
### 变更内容
- 新增 `search +recommend`,列出平台推荐/精选项目,封装此前无 shortcut 的
`GET /api/projects/recommend`
- 无需参数,遵循全局 `--format`;对该接口返回的裸 JSON 数组做结构化归一,输出整洁。
### 价值
为项目发现提供入口(科研选题/技术调研时可快速看到平台精选项目),补全 Raw API 封装。

View File

@ -90,6 +90,8 @@
"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",
"cmd.search.recommend.long": "List the platform's recommended/featured projects (id, name, visits, author, category).",
"cmd.search.recommend.short": "List recommended projects",
"cmd.search.repos.short": "Search repositories",
"cmd.search.short": "Search operations",
"cmd.search.users.short": "Search users",

View File

@ -90,6 +90,8 @@
"cmd.repo.tree.short": "列出仓库文件和目录",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
"cmd.search.recommend.long": "列出平台推荐/精选项目ID、名称、访问量、作者、分类。",
"cmd.search.recommend.short": "列出推荐项目",
"cmd.search.repos.short": "搜索仓库",
"cmd.search.short": "搜索操作",
"cmd.search.users.short": "搜索用户",

View File

@ -1,6 +1,7 @@
package search
import (
"encoding/json"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
@ -52,6 +53,26 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "recommend",
Description: tr.T("cmd.search.recommend.short"),
Long: tr.T("cmd.search.recommend.long"),
Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/projects/recommend", nil)
if err != nil {
return err
}
// This endpoint returns a bare JSON array, which the client
// surfaces as a raw string; normalize it to structured data.
if s, ok := env.Data.(string); ok {
var arr interface{}
if json.Unmarshal([]byte(s), &arr) == nil {
return ctx.OutputData(arr)
}
}
return ctx.Output(env)
},
},
}
}

View File

@ -83,6 +83,37 @@ func TestSearchUsers(t *testing.T) {
}
}
// --- recommend ---
func TestSearchRecommend(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/projects/recommend.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
// bare JSON array (matches the real endpoint shape)
writeJSON(w, []interface{}{
map[string]interface{}{"identifier": "forgeplus", "name": "确实开源", "visits": float64(48497)},
})
}))
defer server.Close()
if err := runShortcut(t, server, "recommend", nil); err != nil {
t.Fatalf("recommend failed: %v", err)
}
}
func TestSearchRecommendHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
if err := runShortcut(t, server, "recommend", nil); err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// --- HTTP error paths ---
func TestSearchReposHTTPError(t *testing.T) {