feat: add repository reaction shortcuts
This commit is contained in:
parent
52b7093846
commit
490e004cec
20
README.md
20
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 |
|
||||
|
|
|
|||
|
|
@ -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` | 仓库成员与邀请链接管理 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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: ",
|
||||
|
|
|
|||
|
|
@ -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": "用户名/邮箱/手机号:",
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue