From 20c3a4ff62b0cea370eeedba10cee16b92dfd692 Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Thu, 14 May 2026 14:23:04 +0800 Subject: [PATCH] feat(pr): add pr +comment shortcut Add a convenience command to comment on pull requests directly, without needing to look up the underlying issue ID first. Internally resolves the PR's issue ID via pr +view, then posts to the issues journals API. --- shortcuts/pr/pr.go | 50 +++++++++++++ shortcuts/pr/pr_test.go | 143 +++++++++++++++++++++++++++++++++++++ skills/gitlink-pr/SKILL.md | 4 ++ 3 files changed, 197 insertions(+) create mode 100644 shortcuts/pr/pr_test.go diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index aefc500..5f4a713 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -166,5 +167,54 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "comment", + Description: "Add a comment to a pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id") + body, _ := ctx.RequireArg("body") + + prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("fetch PR: %w", err) + } + issueID, err := extractIssueID(prEnv) + if err != nil { + return err + } + + payload := map[string]interface{}{ + "notes": body, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } + +func extractIssueID(env *output.Envelope) (int64, error) { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("unexpected PR response format") + } + issue, ok := data["issue"].(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("PR response missing issue field") + } + idFloat, ok := issue["id"].(float64) + if !ok { + return 0, fmt.Errorf("PR response missing issue.id field") + } + return int64(idFloat), nil +} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go new file mode 100644 index 0000000..b80ab60 --- /dev/null +++ b/shortcuts/pr/pr_test.go @@ -0,0 +1,143 @@ +package pr + +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 TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { + var journalPayload map[string]interface{} + var journalPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/13.json": + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142301), + "subject": "test PR", + }, + "pull_request": map[string]interface{}{ + "id": float64(14791), + }, + }) + case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/142301/journals.json": + journalPath = r.URL.Path + journalPayload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{ + "id": float64(12345), + "message": "评论成功", + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runPRShortcut(t, server, "comment", map[string]string{ + "id": "13", + "body": "LGTM, looks good!", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + + if journalPath == "" { + t.Fatal("journal endpoint was not called") + } + assertEqual(t, journalPayload["notes"], "LGTM, looks good!") +} + +func TestPRCommentFailsWhenPRNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + writeJSON(t, w, map[string]interface{}{ + "status": 404, + "error": "Not Found", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "comment", map[string]string{ + "id": "999", + "body": "test", + }) + if err == nil { + t.Fatal("expected error for non-existent PR, got nil") + } +} + +func TestPRCommentFailsWhenIssueFieldMissing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{ + "pull_request": map[string]interface{}{ + "id": float64(14791), + }, + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "comment", map[string]string{ + "id": "13", + "body": "test", + }) + if err == nil { + t.Fatal("expected error when issue field is missing, got nil") + } +} + +func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findPRShortcut(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 findPRShortcut(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 decodeJSON(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("failed to decode request body: %v", err) + } + return payload +} + +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/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index 0412f7e..3462ca2 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -26,6 +26,7 @@ metadata: | `pr +close` | 关闭 PR | 是 | | `pr +files` | 变更文件列表 | 否 | | `pr +diff` | 查看提交列表 | 否 | +| `pr +comment` | 给 PR 添加评论 | 是 | ## 使用示例 @@ -48,6 +49,9 @@ gitlink-cli pr +close --id 3 # 查看变更文件(含 diff 内容) gitlink-cli pr +files --id 3 + +# 给 PR 添加评论 +gitlink-cli pr +comment --id 3 --body "LGTM, ready to merge" ``` ## 创建 PR 的完整流程