feat: add repository reaction shortcuts

This commit is contained in:
Mengz 2026-05-27 17:00:03 +08:00
parent 52b7093846
commit 490e004cec
11 changed files with 577 additions and 17 deletions

View File

@ -96,6 +96,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| Category | Capabilities |
|----------|-------------|
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
| ⭐ Reaction | Follow, unfollow, like, unlike, list watchers and stargazers |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
@ -246,6 +247,24 @@ gitlink-cli repo +create -n my-project -d "Project description"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### Repository Reactions
```bash
# List repository watchers
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus
# List repository stargazers
gitlink-cli reaction +stargazers --owner Gitlink --repo forgeplus
# Follow or unfollow a repository
gitlink-cli reaction +follow --owner Gitlink --repo forgeplus
gitlink-cli reaction +unfollow --owner Gitlink --repo forgeplus
# Like or unlike a repository
gitlink-cli reaction +like --owner Gitlink --repo forgeplus
gitlink-cli reaction +unlike --owner Gitlink --repo forgeplus
```
### Webhook Management
```bash
@ -638,6 +657,7 @@ See [skills/README.md](skills/README.md) for details.
|-------|-------------|
| `gitlink-shared` | Authentication, global parameters, safety rules, API notes |
| `gitlink-repo` | Repository operations (create, view, delete, fork, insights, etc.) |
| `gitlink-reaction` | Repository reactions (follow, like, watchers, stargazers) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |

View File

@ -96,6 +96,7 @@
| 分类 | 能力 |
|------|------|
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息、洞察数据和互动状态 |
| ⭐ 互动 | 关注、取消关注、点赞、取消点赞,查看关注者和点赞者 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
@ -257,6 +258,24 @@ gitlink-cli repo +create -n my-project -d "项目描述"
gitlink-cli repo +fork --owner Gitlink --repo forgeplus
```
### 仓库互动
```bash
# 列出仓库关注者
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus
# 列出仓库点赞者
gitlink-cli reaction +stargazers --owner Gitlink --repo forgeplus
# 关注或取消关注仓库
gitlink-cli reaction +follow --owner Gitlink --repo forgeplus
gitlink-cli reaction +unfollow --owner Gitlink --repo forgeplus
# 点赞或取消点赞仓库
gitlink-cli reaction +like --owner Gitlink --repo forgeplus
gitlink-cli reaction +unlike --owner Gitlink --repo forgeplus
```
### Webhook 管理
```bash
@ -517,6 +536,7 @@ git push gitlink
|-------|------|
| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 |
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork、洞察数据等 |
| `gitlink-reaction` | 仓库互动(关注、点赞、关注者、点赞者) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |

View File

@ -0,0 +1,26 @@
# Reaction Shortcut
## Summary
Adds a `reaction` shortcut group for repository social interactions. The group exposes watcher and stargazer lists plus follow/unfollow and like/unlike actions.
## Commands
| Command | Purpose |
|---------|---------|
| `gitlink-cli reaction +watchers` | List repository watchers |
| `gitlink-cli reaction +stargazers` | List repository stargazers |
| `gitlink-cli reaction +follow` | Follow a repository |
| `gitlink-cli reaction +unfollow` | Unfollow a repository |
| `gitlink-cli reaction +like` | Like a repository |
| `gitlink-cli reaction +unlike` | Unlike a repository |
## Behavior
- `+watchers` and `+stargazers` support optional `--start-at` and `--end-at` Unix timestamp filters.
- Write actions accept optional `--project-id`; if omitted, the shortcut resolves the project ID from `--owner/--repo` automatically.
- Timestamp and project ID inputs are validated before API calls are sent.
## Tests
The unit tests verify list query parameters, project ID auto-resolution, explicit project ID handling, follow/unfollow endpoints, like/unlike endpoints, and validation failures.

View File

@ -54,6 +54,13 @@
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.view.short": "View pull request details",
"cmd.reaction.follow.short": "Follow a repository",
"cmd.reaction.like.short": "Like a repository",
"cmd.reaction.short": "Repository reaction operations",
"cmd.reaction.stargazers.short": "List users who liked the repository",
"cmd.reaction.unfollow.short": "Unfollow a repository",
"cmd.reaction.unlike.short": "Unlike a repository",
"cmd.reaction.watchers.short": "List repository watchers",
"cmd.release.create.short": "Create a release",
"cmd.release.delete.short": "Delete a release",
"cmd.release.list.short": "List releases",
@ -90,13 +97,13 @@
"error.config.save_failed": "failed to save config: {message}",
"error.missing_required_flag": "required flag --{name} is missing",
"error.unsupported_language": "unsupported language: {lang}",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure",
"flag.api.batch_dry_run": "Preview batch requests without sending remote requests",
"flag.api.batch_file": "Read an API batch plan from a JSON file",
"flag.api.batch_var": "Override a batch template variable (key=value, repeatable)",
"flag.api.body": "Request body (JSON string)",
"flag.api.body_file": "Read request body JSON from a file",
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.header": "Additional headers (key:value)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.auth.token": "Login by pasting an existing token",
@ -113,6 +120,8 @@
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
@ -120,8 +129,6 @@
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.assignee_id": "Assignee user ID",
"flag.issue.author_id": "Author user ID",
"flag.issue.body": "Issue description",
"flag.issue.label": "Label ID",
"flag.issue.label_filter": "Filter by existing label",
@ -149,18 +156,21 @@
"flag.pr.file": "Filter diff by file path",
"flag.pr.head": "Source branch",
"flag.pr.id": "PR number",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.milestone_id": "Milestone ID",
"flag.pr.priority_id": "Priority ID",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.pr.review_content": "Review content",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.reviewer_id": "Reviewer user ID",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.tag_id": "Issue tag ID",
"flag.pr.title": "PR title",
"flag.pr.version_id": "Patchset version ID",
"flag.reaction.end_at": "End Unix timestamp",
"flag.reaction.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.",
"flag.reaction.start_at": "Start Unix timestamp",
"flag.release.body": "Release notes",
"flag.release.id": "Release ID",
"flag.release.id_or_tag": "Release ID or tag",
@ -194,7 +204,6 @@
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",
"output.doctor.api_auth.failed": "Authenticated API request failed: {message}",
"output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.",
@ -217,6 +226,7 @@
"output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.",
"output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.",
"output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "Password: ",
"prompt.auth.token": "Paste your access token: ",
"prompt.auth.username": "Username/Email/Phone: ",

View File

@ -54,6 +54,13 @@
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.view.short": "查看拉取请求详情",
"cmd.reaction.follow.short": "关注仓库",
"cmd.reaction.like.short": "点赞仓库",
"cmd.reaction.short": "仓库互动操作",
"cmd.reaction.stargazers.short": "列出仓库点赞用户",
"cmd.reaction.unfollow.short": "取消关注仓库",
"cmd.reaction.unlike.short": "取消点赞仓库",
"cmd.reaction.watchers.short": "列出仓库关注用户",
"cmd.release.create.short": "创建发布",
"cmd.release.delete.short": "删除发布",
"cmd.release.list.short": "列出发布",
@ -90,13 +97,13 @@
"error.config.save_failed": "保存配置失败:{message}",
"error.missing_required_flag": "缺少必需参数 --{name}",
"error.unsupported_language": "不支持的语言:{lang}",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求",
"flag.api.batch_dry_run": "预览批处理请求,不发送远端请求",
"flag.api.batch_file": "从 JSON 文件读取 API 批处理计划",
"flag.api.batch_var": "覆盖批处理模板变量key=value可重复",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.body_file": "从文件读取 JSON 请求体",
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.header": "附加请求头key:value",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.auth.token": "通过粘贴已有 Token 登录",
@ -113,6 +120,8 @@
"flag.format": "输出格式json、table、yaml默认table",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
@ -120,8 +129,6 @@
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.assignee_id": "负责人用户 ID",
"flag.issue.author_id": "作者用户 ID",
"flag.issue.body": "议题描述",
"flag.issue.label": "标签 ID",
"flag.issue.label_filter": "按已有标签筛选",
@ -149,18 +156,21 @@
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.head": "源分支",
"flag.pr.id": "PR 编号",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.milestone_id": "里程碑 ID",
"flag.pr.priority_id": "优先级 ID",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.pr.review_content": "评审内容",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.reviewer_id": "评审人用户 ID",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.tag_id": "议题标签 ID",
"flag.pr.title": "PR 标题",
"flag.pr.version_id": "补丁集版本 ID",
"flag.reaction.end_at": "结束 Unix 时间戳",
"flag.reaction.project_id": "GitLink 项目 ID。未提供时会根据 --owner/--repo 自动解析。",
"flag.reaction.start_at": "开始 Unix 时间戳",
"flag.release.body": "发布说明",
"flag.release.id": "发布 ID",
"flag.release.id_or_tag": "发布 ID 或标签",
@ -194,7 +204,6 @@
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.version": "gitlink-cli {version}",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",
"output.doctor.api_auth.failed": "认证 API 请求失败:{message}",
"output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。",
@ -217,6 +226,7 @@
"output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。",
"output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。",
"output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。",
"output.version": "gitlink-cli {version}",
"prompt.auth.password": "密码:",
"prompt.auth.token": "粘贴你的访问 Token",
"prompt.auth.username": "用户名/邮箱/手机号:",

View File

@ -0,0 +1,211 @@
package reaction
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return []*common.Shortcut{
{
Name: "watchers",
Description: tr.T("cmd.reaction.watchers.short"),
Flags: timeRangeFlags(tr),
Run: runWatchers,
},
{
Name: "stargazers",
Description: tr.T("cmd.reaction.stargazers.short"),
Flags: timeRangeFlags(tr),
Run: runStargazers,
},
{
Name: "follow",
Description: tr.T("cmd.reaction.follow.short"),
Flags: projectIDFlags(tr),
Run: runFollow,
},
{
Name: "unfollow",
Description: tr.T("cmd.reaction.unfollow.short"),
Flags: projectIDFlags(tr),
Run: runUnfollow,
},
{
Name: "like",
Description: tr.T("cmd.reaction.like.short"),
Flags: projectIDFlags(tr),
Run: runLike,
},
{
Name: "unlike",
Description: tr.T("cmd.reaction.unlike.short"),
Flags: projectIDFlags(tr),
Run: runUnlike,
},
}
}
func timeRangeFlags(tr *i18n.Translator) []common.Flag {
return []common.Flag{
{Name: "start-at", Usage: tr.T("flag.reaction.start_at")},
{Name: "end-at", Usage: tr.T("flag.reaction.end_at")},
}
}
func projectIDFlags(tr *i18n.Translator) []common.Flag {
return []common.Flag{
{Name: "project-id", Usage: tr.T("flag.reaction.project_id")},
}
}
func runWatchers(ctx *common.RuntimeContext) error {
return runUserList(ctx, "watchers")
}
func runStargazers(ctx *common.RuntimeContext) error {
return runUserList(ctx, "stargazers")
}
func runUserList(ctx *common.RuntimeContext, kind string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
query, err := timeRangeQuery(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/"+kind, query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runFollow(ctx *common.RuntimeContext) error {
return runFollowAction(ctx, "POST", "/watchers/follow")
}
func runUnfollow(ctx *common.RuntimeContext) error {
return runFollowAction(ctx, "DELETE", "/watchers/unfollow")
}
func runFollowAction(ctx *common.RuntimeContext, method, path string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
query := url.Values{}
query.Set("target_type", "project")
query.Set("id", projectID)
env, err := ctx.CallAPIWithQuery(method, path, query)
if err != nil {
return err
}
return ctx.Output(env)
}
func runLike(ctx *common.RuntimeContext) error {
return runPraiseAction(ctx, "POST", "like")
}
func runUnlike(ctx *common.RuntimeContext) error {
return runPraiseAction(ctx, "DELETE", "unlike")
}
func runPraiseAction(ctx *common.RuntimeContext, method, action string) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI(method, fmt.Sprintf("/projects/%s/praise_tread/%s", projectID, action), nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func timeRangeQuery(ctx *common.RuntimeContext) (url.Values, error) {
query := url.Values{}
if start := strings.TrimSpace(ctx.Arg("start-at")); start != "" {
if err := validateUnixTimestamp("start-at", start); err != nil {
return nil, err
}
query.Set("start_at", start)
}
if end := strings.TrimSpace(ctx.Arg("end-at")); end != "" {
if err := validateUnixTimestamp("end-at", end); err != nil {
return nil, err
}
query.Set("end_at", end)
}
return query, nil
}
func validateUnixTimestamp(name, value string) error {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return fmt.Errorf("invalid --%s %q: use a non-negative Unix timestamp", name, value)
}
return nil
}
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
if raw := strings.TrimSpace(ctx.Arg("project-id")); raw != "" {
return normalizeProjectID(raw)
}
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return "", fmt.Errorf("resolve project id: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("resolve project id: unexpected repository response")
}
for _, key := range []string{"id", "project_id"} {
if id := projectIDString(data[key]); id != "" {
return id, nil
}
}
return "", fmt.Errorf("resolve project id: repository response did not include id")
}
func normalizeProjectID(value string) (string, error) {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed <= 0 {
return "", fmt.Errorf("invalid --project-id %q: use a positive numeric project ID", value)
}
return value, nil
}
func projectIDString(value interface{}) string {
switch v := value.(type) {
case float64:
return strconv.FormatInt(int64(v), 10)
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case string:
id, err := normalizeProjectID(strings.TrimSpace(v))
if err == nil {
return id
}
}
return ""
}

View File

@ -0,0 +1,215 @@
package reaction
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestReactionWatchersBuildsTimeRangeQuery(t *testing.T) {
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/watchers.json")
assertEqual(t, r.URL.Query().Get("start_at"), "1700000000")
assertEqual(t, r.URL.Query().Get("end_at"), "1700003600")
writeJSON(t, w, map[string]interface{}{"count": 0, "users": []interface{}{}})
})
defer server.Close()
err := runReactionShortcut(t, server, "watchers", map[string]string{
"start-at": "1700000000",
"end-at": "1700003600",
})
if err != nil {
t.Fatalf("watchers shortcut failed: %v", err)
}
}
func TestReactionStargazers(t *testing.T) {
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/owner/repo/stargazers.json")
writeJSON(t, w, map[string]interface{}{"count": 1, "users": []interface{}{}})
})
defer server.Close()
if err := runReactionShortcut(t, server, "stargazers", nil); err != nil {
t.Fatalf("stargazers shortcut failed: %v", err)
}
}
func TestReactionFollowResolvesProjectID(t *testing.T) {
requests := 0
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requests++
switch requests {
case 1:
assertRequest(t, r, "GET", "/owner/repo.json")
writeJSON(t, w, map[string]interface{}{"id": float64(123)})
case 2:
assertRequest(t, r, "POST", "/watchers/follow.json")
assertEqual(t, r.URL.Query().Get("target_type"), "project")
assertEqual(t, r.URL.Query().Get("id"), "123")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": true})
default:
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
if err := runReactionShortcut(t, server, "follow", nil); err != nil {
t.Fatalf("follow shortcut failed: %v", err)
}
assertEqual(t, requests, 2)
}
func TestReactionUnfollowUsesExplicitProjectID(t *testing.T) {
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/watchers/unfollow.json")
assertEqual(t, r.URL.Query().Get("target_type"), "project")
assertEqual(t, r.URL.Query().Get("id"), "456")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success", "watched": false})
})
defer server.Close()
if err := runReactionShortcut(t, server, "unfollow", map[string]string{"project-id": "456"}); err != nil {
t.Fatalf("unfollow shortcut failed: %v", err)
}
}
func TestReactionLikeUsesExplicitProjectID(t *testing.T) {
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/projects/456/praise_tread/like.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
if err := runReactionShortcut(t, server, "like", map[string]string{"project-id": "456"}); err != nil {
t.Fatalf("like shortcut failed: %v", err)
}
}
func TestReactionUnlikeResolvesStringProjectID(t *testing.T) {
requests := 0
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
requests++
switch requests {
case 1:
assertRequest(t, r, "GET", "/owner/repo.json")
writeJSON(t, w, map[string]interface{}{"project_id": "789"})
case 2:
assertRequest(t, r, "DELETE", "/projects/789/praise_tread/unlike.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
default:
t.Fatalf("unexpected extra request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
if err := runReactionShortcut(t, server, "unlike", nil); err != nil {
t.Fatalf("unlike shortcut failed: %v", err)
}
assertEqual(t, requests, 2)
}
func TestReactionValidation(t *testing.T) {
server := newReactionTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid input should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
cases := []struct {
name string
shortcut string
args map[string]string
}{
{
name: "invalid start",
shortcut: "watchers",
args: map[string]string{"start-at": "soon"},
},
{
name: "negative end",
shortcut: "stargazers",
args: map[string]string{"end-at": "-1"},
},
{
name: "invalid project id",
shortcut: "like",
args: map[string]string{"project-id": "repo"},
},
{
name: "negative project id",
shortcut: "like",
args: map[string]string{"project-id": "-1"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runReactionShortcut(t, server, tc.shortcut, tc.args); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func runReactionShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findReactionShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findReactionShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newReactionTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -17,6 +17,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/reaction"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
@ -40,6 +41,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"reaction": reaction.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
@ -61,6 +63,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"reaction": tr.T("cmd.reaction.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"health",
"health", "reaction",
}
groupSet := map[string]bool{}

View File

@ -73,6 +73,8 @@ skills/
│ ├── REFERENCE.md # 仓库 API 参考
│ └── examples/
│ └── repo-workflow.md # 仓库管理工作流
├── gitlink-reaction/ # 仓库互动
│ └── SKILL.md # 关注、点赞和用户列表操作指南
├── gitlink-issue/ # Issue 管理
│ ├── SKILL.md # Issue 操作指南
│ ├── REFERENCE.md # Issue API 参考
@ -135,6 +137,7 @@ skills/
|-------|------|----------|
| **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` |
| **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` |
| **gitlink-reaction** | 仓库互动 | `reaction +watchers`, `reaction +stargazers`, `reaction +follow`, `reaction +like` |
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |

View File

@ -0,0 +1,42 @@
---
name: gitlink-reaction
version: 1.0.0
description: "Repository reactions: list watchers/stargazers, follow or unfollow repositories, and like or unlike repositories."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli reaction --help"
---
# gitlink-reaction
Use this skill when an agent needs to inspect or change repository social interaction state in GitLink.
## Shortcuts
| Shortcut | Purpose |
|----------|---------|
| `reaction +watchers` | List repository watchers |
| `reaction +stargazers` | List repository stargazers |
| `reaction +follow` | Follow a repository |
| `reaction +unfollow` | Unfollow a repository |
| `reaction +like` | Like a repository |
| `reaction +unlike` | Unlike a repository |
## Examples
```bash
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus
gitlink-cli reaction +watchers --owner Gitlink --repo forgeplus --start-at 1700000000 --end-at 1700003600
gitlink-cli reaction +stargazers --owner Gitlink --repo forgeplus
gitlink-cli reaction +follow --owner Gitlink --repo forgeplus
gitlink-cli reaction +unfollow --owner Gitlink --repo forgeplus
gitlink-cli reaction +like --owner Gitlink --repo forgeplus
gitlink-cli reaction +unlike --owner Gitlink --repo forgeplus
```
## Safety Notes
- Confirm the target repository before follow, unfollow, like, or unlike actions.
- Use `--project-id` when the caller already knows the numeric project ID to avoid the extra repository lookup.
- `--start-at` and `--end-at` must be Unix timestamps.