diff --git a/README.md b/README.md index b43dcd1..4abf3fe 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,19 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### Feedback + +```bash +# Preview a feedback submission without calling the API +gitlink-cli feedback +create --content "The CLI install guide needs a Windows note." --category docs --dry-run + +# Submit longer feedback from a file and attach contact/repository context +gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com --repo-ref Gitlink/gitlink-cli + +# Pipe feedback from another command +Get-Content feedback.md | gitlink-cli feedback +create --stdin --category feature +``` + ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..a48b3c9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -455,6 +455,19 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 反馈建议 + +```bash +# 预览反馈提交,不调用 API +gitlink-cli feedback +create --content "CLI 安装文档需要补充 Windows 说明。" --category docs --dry-run + +# 从文件提交较长反馈,并附带联系方式和相关仓库 +gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com --repo-ref Gitlink/gitlink-cli + +# 从管道读取反馈内容 +Get-Content feedback.md | gitlink-cli feedback +create --stdin --category feature +``` + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/feedback-shortcut.md b/doc/changes/feedback-shortcut.md new file mode 100644 index 0000000..2a817bf --- /dev/null +++ b/doc/changes/feedback-shortcut.md @@ -0,0 +1,26 @@ +# Feedback shortcut + +This change adds a dedicated `feedback` shortcut group for submitting GitLink platform feedback from the CLI. + +New command: + +- `feedback +create` + +The command wraps `POST /api/v1/{owner}/feedbacks.json` and improves the CLI experience around the narrow API payload: + +- Resolves the current authenticated user with `GET /users/me` when `--user` is omitted. +- Accepts feedback text from `--content`, `--from`, and `--stdin`, combining multiple sources with blank lines. +- Adds optional metadata lines for `--category`, `--contact`, and `--repo-ref` before the body. +- Supports `--dry-run` to preview method, path, payload, and content length without submitting. +- Rejects empty feedback before making any API request. + +Documentation was added to README, README.zh-CN, and `skills/gitlink-feedback/SKILL.md`. + +Verification: + +- `go test ./shortcuts/feedback` +- `go test ./shortcuts` +- `go test ./...` +- `go build ./...` +- `git diff --check` +- UTF-8 mojibake scan on touched files diff --git a/shortcuts/feedback/feedback.go b/shortcuts/feedback/feedback.go new file mode 100644 index 0000000..2caa20c --- /dev/null +++ b/shortcuts/feedback/feedback.go @@ -0,0 +1,141 @@ +package feedback + +import ( + "fmt" + "io" + "net/url" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var feedbackInput io.Reader = os.Stdin + +// Shortcuts returns GitLink platform feedback shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "create", + Description: "Submit feedback or suggestions to GitLink", + Flags: []common.Flag{ + {Name: "user", Short: "u", Usage: "GitLink user login. Defaults to current authenticated user"}, + {Name: "content", Short: "c", Usage: "Feedback content"}, + {Name: "from", Short: "f", Usage: "Read feedback content from a text file"}, + {Name: "stdin", Usage: "Read feedback content from standard input", Bool: true, Default: "false"}, + {Name: "category", Usage: "Optional feedback category, for example bug, feature, docs, ux, or cli"}, + {Name: "contact", Usage: "Optional contact information to include in the feedback"}, + {Name: "repo-ref", Usage: "Optional related repository in owner/repo form"}, + {Name: "dry-run", Usage: "Preview the request without submitting feedback", Bool: true, Default: "false"}, + }, + Run: runCreate, + }, + } +} + +func runCreate(ctx *common.RuntimeContext) error { + user, err := resolveFeedbackUser(ctx) + if err != nil { + return err + } + content, err := buildFeedbackContent(ctx) + if err != nil { + return err + } + payload := map[string]interface{}{"content": content} + path := feedbackPath(user) + if parseFeedbackBool(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + "user": user, + "content_length": len(content), + "body": payload, + }) + } + env, err := ctx.CallAPI("POST", path, payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func resolveFeedbackUser(ctx *common.RuntimeContext) (string, error) { + if user := strings.TrimSpace(ctx.Arg("user")); user != "" { + return user, nil + } + env, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("cannot determine current user login") + } + login, _ := data["login"].(string) + login = strings.TrimSpace(login) + if login == "" { + return "", fmt.Errorf("cannot determine current user login") + } + return login, nil +} + +func buildFeedbackContent(ctx *common.RuntimeContext) (string, error) { + parts := make([]string, 0, 3) + if content := strings.TrimSpace(ctx.Arg("content")); content != "" { + parts = append(parts, content) + } + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read --from: %w", err) + } + if text := strings.TrimSpace(string(content)); text != "" { + parts = append(parts, text) + } + } + if parseFeedbackBool(ctx.Arg("stdin")) { + content, err := io.ReadAll(feedbackInput) + if err != nil { + return "", fmt.Errorf("read --stdin: %w", err) + } + if text := strings.TrimSpace(string(content)); text != "" { + parts = append(parts, text) + } + } + if len(parts) == 0 { + return "", fmt.Errorf("provide feedback content with --content, --from, or --stdin") + } + + metadata := feedbackMetadata(ctx) + body := strings.Join(parts, "\n\n") + if len(metadata) == 0 { + return body, nil + } + return strings.Join(append(metadata, "", body), "\n"), nil +} + +func feedbackMetadata(ctx *common.RuntimeContext) []string { + var lines []string + if category := strings.TrimSpace(ctx.Arg("category")); category != "" { + lines = append(lines, "Category: "+category) + } + if repoRef := strings.TrimSpace(ctx.Arg("repo-ref")); repoRef != "" { + lines = append(lines, "Repository: "+repoRef) + } else if strings.TrimSpace(ctx.Owner) != "" && strings.TrimSpace(ctx.Repo) != "" { + lines = append(lines, fmt.Sprintf("Repository: %s/%s", strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo))) + } + if contact := strings.TrimSpace(ctx.Arg("contact")); contact != "" { + lines = append(lines, "Contact: "+contact) + } + return lines +} + +func feedbackPath(user string) string { + return fmt.Sprintf("/v1/%s/feedbacks", url.PathEscape(user)) +} + +func parseFeedbackBool(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} diff --git a/shortcuts/feedback/feedback_test.go b/shortcuts/feedback/feedback_test.go new file mode 100644 index 0000000..40b9a92 --- /dev/null +++ b/shortcuts/feedback/feedback_test.go @@ -0,0 +1,204 @@ +package feedback + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFeedbackCreateWithExplicitUser(t *testing.T) { + var payload map[string]interface{} + server := newFeedbackServer(t, func(w http.ResponseWriter, r *http.Request) { + assertFeedbackRequest(t, r, "POST", "/v1/Mengz/feedbacks.json") + payload = decodeFeedbackJSON(t, r) + writeFeedbackJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runFeedbackShortcut(t, server, "create", map[string]string{ + "user": "Mengz", + "content": "The CLI should support feedback.", + "category": "feature", + "contact": "mengz@example.com", + "repo-ref": "Gitlink/gitlink-cli", + }) + if err != nil { + t.Fatalf("feedback create failed: %v", err) + } + content, _ := payload["content"].(string) + for _, want := range []string{ + "Category: feature", + "Repository: Gitlink/gitlink-cli", + "Contact: mengz@example.com", + "The CLI should support feedback.", + } { + if !strings.Contains(content, want) { + t.Fatalf("content missing %q: %q", want, content) + } + } +} + +func TestFeedbackCreateDefaultsToCurrentUser(t *testing.T) { + var paths []string + server := newFeedbackServer(t, func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/users/me.json": + assertFeedbackRequest(t, r, "GET", "/users/me.json") + writeFeedbackJSON(t, w, map[string]interface{}{"login": "Mengz"}) + case "/v1/Mengz/feedbacks.json": + assertFeedbackRequest(t, r, "POST", "/v1/Mengz/feedbacks.json") + writeFeedbackJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runFeedbackShortcut(t, server, "create", map[string]string{"content": "Feedback body"}) + if err != nil { + t.Fatalf("feedback create failed: %v", err) + } + want := []string{"/users/me.json", "/v1/Mengz/feedbacks.json"} + if strings.Join(paths, ",") != strings.Join(want, ",") { + t.Fatalf("paths = %v, want %v", paths, want) + } +} + +func TestFeedbackCreateReadsFileAndStdin(t *testing.T) { + oldInput := feedbackInput + feedbackInput = strings.NewReader("stdin details\n") + defer func() { feedbackInput = oldInput }() + + var payload map[string]interface{} + server := newFeedbackServer(t, func(w http.ResponseWriter, r *http.Request) { + assertFeedbackRequest(t, r, "POST", "/v1/Mengz/feedbacks.json") + payload = decodeFeedbackJSON(t, r) + writeFeedbackJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + path := filepath.Join(t.TempDir(), "feedback.txt") + if err := os.WriteFile(path, []byte("file details\n"), 0o600); err != nil { + t.Fatalf("write feedback file: %v", err) + } + err := runFeedbackShortcut(t, server, "create", map[string]string{ + "user": "Mengz", + "from": path, + "stdin": "true", + }) + if err != nil { + t.Fatalf("feedback create failed: %v", err) + } + content, _ := payload["content"].(string) + if !strings.Contains(content, "file details") || !strings.Contains(content, "stdin details") { + t.Fatalf("content = %q, want file and stdin details", content) + } +} + +func TestFeedbackCreateDryRunDoesNotCallSubmitAPI(t *testing.T) { + server := newFeedbackServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runFeedbackShortcut(t, server, "create", map[string]string{ + "user": "Mengz", + "content": "Preview this feedback", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("feedback dry-run failed: %v", err) + } +} + +func TestFeedbackCreateRejectsEmptyContentBeforeAPI(t *testing.T) { + server := newFeedbackServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("empty content should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runFeedbackShortcut(t, server, "create", map[string]string{"user": "Mengz"}) + if err == nil { + t.Fatal("expected empty feedback to return an error") + } +} + +func TestBuildFeedbackContentAddsRepositoryFromContext(t *testing.T) { + ctx := &common.RuntimeContext{ + Owner: "Gitlink", + Repo: "gitlink-cli", + Args: map[string]string{"content": "Feedback body"}, + } + content, err := buildFeedbackContent(ctx) + if err != nil { + t.Fatalf("buildFeedbackContent failed: %v", err) + } + if !strings.Contains(content, "Repository: Gitlink/gitlink-cli") { + t.Fatalf("content = %q, want repository metadata", content) + } +} + +func runFeedbackShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findFeedbackShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findFeedbackShortcut(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 newFeedbackServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertFeedbackRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeFeedbackJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request body: %v", err) + } + return payload +} + +func writeFeedbackJSON(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("write response: %v", err) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 917bf85..8da8cdc 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -8,6 +8,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" + "github.com/gitlink-org/gitlink-cli/shortcuts/feedback" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" @@ -47,6 +48,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), + "feedback": feedback.Shortcuts(), "webhook": webhook.Shortcuts(tr), "health": health.Shortcuts(tr), "workflow": workflow.Shortcuts(), @@ -68,6 +70,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", + "feedback": "Submit feedback and suggestions", "webhook": tr.T("cmd.webhook.short"), "health": "Project health data collection", "workflow": "AI agent workflow analysis", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a8c8ce4..f64ac6f 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", "feedback", } groupSet := map[string]bool{} diff --git a/skills/gitlink-feedback/SKILL.md b/skills/gitlink-feedback/SKILL.md new file mode 100644 index 0000000..8010daa --- /dev/null +++ b/skills/gitlink-feedback/SKILL.md @@ -0,0 +1,52 @@ +--- +name: gitlink-feedback +description: "GitLink 反馈建议提交:从命令行提交平台问题、改进建议或 CLI 使用反馈,支持文件、stdin、dry-run 和上下文元数据。" +metadata: + cliHelp: "gitlink-cli feedback --help" +--- + +# gitlink-feedback + +当用户需要向 GitLink 平台提交问题反馈、体验建议或 CLI 改进意见时使用本 Skill。底层接口只接收 `content`,CLI 会把分类、联系方式和相关仓库作为文本元数据拼入反馈正文,便于平台侧处理。 + +## 常用命令 + +| 命令 | 用途 | +|------|------| +| `feedback +create` | 提交反馈建议 | + +## 示例 + +```bash +# 直接提交简短反馈 +gitlink-cli feedback +create --content "CLI 安装文档需要补充 Windows 说明。" --category docs + +# 提交前预览请求路径、正文长度和 payload +gitlink-cli feedback +create --content "希望支持更多输出格式。" --category feature --dry-run + +# 从文件读取长反馈 +gitlink-cli feedback +create --from feedback.md --category cli --contact mengz@example.com + +# 从管道读取反馈 +Get-Content feedback.md | gitlink-cli feedback +create --stdin --category ux --repo-ref Gitlink/gitlink-cli +``` + +## 参数说明 + +| 参数 | 说明 | +|------|------| +| `--user` / `-u` | GitLink 用户标识;不传时读取当前登录用户 | +| `--content` / `-c` | 直接传入反馈正文 | +| `--from` / `-f` | 从文本文件读取反馈正文 | +| `--stdin` | 从标准输入读取反馈正文 | +| `--category` | 可选分类,如 `bug`、`feature`、`docs`、`ux`、`cli` | +| `--contact` | 可选联系方式,会写入反馈正文 | +| `--repo-ref` | 可选相关仓库,格式建议为 `owner/repo` | +| `--dry-run` | 只预览请求,不提交反馈 | + +## 安全规则 + +- 反馈内容可能进入平台工单或日志,不要提交 Token、Cookie、密码、私钥等敏感信息。 +- 需要提交较长复现信息时优先使用 `--from`,并先用 `--dry-run` 检查最终内容。 +- `--contact` 是明文写入反馈正文,只填写愿意公开给平台维护者的信息。 +- 反馈涉及具体仓库时传 `--repo-ref owner/repo`,不要把仓库上下文混在正文里导致平台侧难以分拣。