Merge PR #405: feat(shortcut): 新增 watch 命令模块,支持仓库关注

# Conflicts:
#	shortcuts/register.go
This commit is contained in:
wbtiger 2026-07-14 22:49:55 +08:00
commit 68e4b42741
5 changed files with 277 additions and 48 deletions

View File

@ -0,0 +1,29 @@
# Watch shortcut
新增 `watch` Shortcut 组,封装 GitLink 仓库关注(订阅)操作:
- `watch +watch` 关注指定仓库
- `watch +unwatch` 取消关注
- `watch +watchers` 列出仓库关注者
实现要点:
- `+watch` 调用 `POST /{owner}/{repo}/watchers/follow``+unwatch` 调用 `DELETE /{owner}/{repo}/watchers/unfollow`
- `+watchers` 调用 `GET /{owner}/{repo}/watchers` 列出关注者。
- 复用 RuntimeContext自动注入 owner/repo/auth与现有 Shortcut 组风格一致。
含单元测试 `shortcuts/watch/watch_test.go`
## Examples
```bash
gitlink-cli watch +watch --owner Gitlink --repo gitlink-cli
gitlink-cli watch +unwatch --owner Gitlink --repo gitlink-cli
gitlink-cli watch +watchers --owner Gitlink --repo gitlink-cli
```
## Tests
```bash
go test ./shortcuts/watch/...
```

View File

@ -15,7 +15,6 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/messagesetting"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
@ -25,6 +24,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
@ -37,55 +37,55 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"message-settings": messagesetting.Shortcuts(tr),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"profile": profile.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"watch": watch.Shortcuts(),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"dataset": dataset.Shortcuts(tr),
"webhook": webhook.Shortcuts(tr),
"wiki": wiki.Shortcuts(),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"message-settings": tr.T("cmd.message_settings.short"),
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"profile": tr.T("cmd.profile.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"watch": "Watch (subscribe) repository operations",
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"dataset": tr.T("cmd.dataset.short"),
"webhook": tr.T("cmd.webhook.short"),
"wiki": "Wiki page management",
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {

View File

@ -15,7 +15,7 @@ func TestRegisterAll(t *testing.T) {
"org", "user", "search", "ci", "workflow",
"message-settings",
"compare", "member", "milestone", "pipeline", "webhook",
"dataset", "health", "ignore", "wiki",
"dataset", "health", "ignore", "wiki", "watch",
}
groupSet := map[string]bool{}

91
shortcuts/watch/watch.go Normal file
View File

@ -0,0 +1,91 @@
package watch
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "watch",
Description: "Watch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unwatch",
Description: "Unwatch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watchers",
Description: "List watchers of a repository",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, _ := ctx.RequireArg("owner")
repo, _ := ctx.RequireArg("repo")
ctx.Owner = owner
ctx.Repo = repo
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"id", "project_id", "repo_id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("cannot find project id in response")
}

View File

@ -0,0 +1,109 @@
package watch
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestWatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100), "name": "repo"})
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
if r.URL.Query().Get("target_type") != "project" {
t.Fatal("expected target_type=project")
}
if r.URL.Query().Get("id") != "100" {
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
}
writeJSON(t, w, map[string]interface{}{"watched": true})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
t.Fatalf("watch failed: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
}
}
func TestUnwatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "DELETE" && r.URL.Path == "/watchers/unfollow.json":
writeJSON(t, w, map[string]interface{}{"watched": false})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
t.Fatalf("unwatch failed: %v", err)
}
}
func TestWatchers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"count": 1,
"users": []map[string]interface{}{{"login": "alice", "is_watch": true}},
})
}))
defer server.Close()
err := runWatchShortcut(t, server, "watchers", map[string]string{
"owner": "owner", "repo": "repo",
})
if err != nil {
t.Fatalf("watchers failed: %v", err)
}
}
// === helpers ===
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWatchShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner", Repo: "repo", Format: "json", Args: args,
}
return shortcut.Run(ctx)
}
func findWatchShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}