feat(feedback): 新增反馈建议快捷命令

This commit is contained in:
Mengz 2026-06-09 19:50:12 +08:00
parent 52b7093846
commit 7c46eb4083
8 changed files with 453 additions and 1 deletions

View File

@ -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:

View File

@ -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 直接调用:

View File

@ -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

View File

@ -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")
}

View File

@ -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)
}
}

View File

@ -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",

View File

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

View File

@ -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`,不要把仓库上下文混在正文里导致平台侧难以分拣。