From 490e004cec5f5a4c8dd7fe8a7f139d81c8141351 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Wed, 27 May 2026 17:00:03 +0800 Subject: [PATCH] feat: add repository reaction shortcuts --- README.md | 20 +++ README.zh-CN.md | 20 +++ doc/changes/reaction-shortcut.md | 26 ++++ internal/i18n/locales/en-US.json | 26 ++-- internal/i18n/locales/zh-CN.json | 26 ++-- shortcuts/reaction/reaction.go | 211 +++++++++++++++++++++++++++ shortcuts/reaction/reaction_test.go | 215 ++++++++++++++++++++++++++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- skills/README.md | 3 + skills/gitlink-reaction/SKILL.md | 42 ++++++ 11 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 doc/changes/reaction-shortcut.md create mode 100644 shortcuts/reaction/reaction.go create mode 100644 shortcuts/reaction/reaction_test.go create mode 100644 skills/gitlink-reaction/SKILL.md diff --git a/README.md b/README.md index b43dcd1..c8bea48 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..276e590 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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` | 仓库成员与邀请链接管理 | diff --git a/doc/changes/reaction-shortcut.md b/doc/changes/reaction-shortcut.md new file mode 100644 index 0000000..f370df9 --- /dev/null +++ b/doc/changes/reaction-shortcut.md @@ -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. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..9c7bc35 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 7fdc2b1..62614b2 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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": "用户名/邮箱/手机号:", diff --git a/shortcuts/reaction/reaction.go b/shortcuts/reaction/reaction.go new file mode 100644 index 0000000..3012245 --- /dev/null +++ b/shortcuts/reaction/reaction.go @@ -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 "" +} diff --git a/shortcuts/reaction/reaction_test.go b/shortcuts/reaction/reaction_test.go new file mode 100644 index 0000000..7f7ef34 --- /dev/null +++ b/shortcuts/reaction/reaction_test.go @@ -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) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 917bf85..d8386c7 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a8c8ce4..a54baf3 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", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "health", + "health", "reaction", } groupSet := map[string]bool{} diff --git a/skills/README.md b/skills/README.md index af78bca..033d41a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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` | diff --git a/skills/gitlink-reaction/SKILL.md b/skills/gitlink-reaction/SKILL.md new file mode 100644 index 0000000..838c855 --- /dev/null +++ b/skills/gitlink-reaction/SKILL.md @@ -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.