feat(journal): add issue and PR comment shortcuts
This commit is contained in:
parent
ef7a2c6ac1
commit
d0f7c15793
18
README.md
18
README.md
|
|
@ -308,13 +308,13 @@ gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed
|
|||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# View an issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 123
|
||||
|
||||
# Update issue metadata
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# Close an issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus --number 123
|
||||
|
||||
# Preview batch close without changing data
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
||||
|
|
@ -323,7 +323,13 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
|
|||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# Add a comment
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "Fixed"
|
||||
|
||||
# List, update, delete, and inspect issue comments/journals
|
||||
gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment
|
||||
gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 58 -b "Updated comment" --dry-run
|
||||
gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 58 --dry-run
|
||||
gitlink-cli issue +comment-children --owner Gitlink --repo forgeplus --number 123 --comment-id 58
|
||||
|
||||
# List issue assigners
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
|
@ -401,6 +407,12 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
|||
# Create a PR review (with dry-run preview)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
|
||||
# List and manage PR review comments
|
||||
gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened
|
||||
gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "Please fix" --dry-run
|
||||
gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --state resolved --dry-run
|
||||
gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run
|
||||
```
|
||||
|
||||
### Branch Management
|
||||
|
|
|
|||
|
|
@ -319,13 +319,13 @@ gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败
|
|||
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
|
||||
|
||||
# 查看 Issue
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 123
|
||||
|
||||
# 更新 Issue 元数据
|
||||
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
|
||||
|
||||
# 关闭 Issue
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus --number 123
|
||||
|
||||
# 预览批量关闭,不修改数据
|
||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
||||
|
|
@ -334,7 +334,13 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,12
|
|||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "已修复"
|
||||
|
||||
# 查看、更新、删除 Issue 评论/操作记录
|
||||
gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment
|
||||
gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 58 -b "更新评论" --dry-run
|
||||
gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 58 --dry-run
|
||||
gitlink-cli issue +comment-children --owner Gitlink --repo forgeplus --number 123 --comment-id 58
|
||||
|
||||
# 列出 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
|
||||
|
|
@ -411,6 +417,12 @@ gitlink-cli pr +reviews --owner Gitlink --repo forgeplus -i 42
|
|||
# 创建 PR 审查(支持 dry-run 预览)
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM" --dry-run
|
||||
gitlink-cli pr +review --owner Gitlink --repo forgeplus -i 42 --status approved -c "LGTM"
|
||||
|
||||
# 查看和管理 PR Review 行评论
|
||||
gitlink-cli pr +review-comments --owner Gitlink --repo forgeplus -i 42 --state opened
|
||||
gitlink-cli pr +review-comment --owner Gitlink --repo forgeplus -i 42 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "请修改" --dry-run
|
||||
gitlink-cli pr +review-comment-update --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --state resolved --dry-run
|
||||
gitlink-cli pr +review-comment-delete --owner Gitlink --repo forgeplus -i 42 --comment-id 200 --dry-run
|
||||
```
|
||||
|
||||
### 发布管理
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
# Issue and PR Journal Shortcuts
|
||||
|
||||
补齐 Issue 评论/操作记录与 Pull Request Review 评论相关 OpenAPI 封装。
|
||||
|
||||
## Issue comments / journals
|
||||
|
||||
新增或增强:
|
||||
|
||||
- `issue +comment`:添加评论,新增 `--parent-id`、`--reply-id`、`--attachment-ids`、`--receivers`、`--dry-run`。
|
||||
- `issue +comments`:查看 Issue 评论和操作记录,支持 `category`、关键字、排序、分页。
|
||||
- `issue +comment-update`:更新 Issue 评论,支持附件、@接收人和 `--dry-run`。
|
||||
- `issue +comment-delete`:删除 Issue 评论,支持 `--dry-run`。
|
||||
- `issue +comment-children`:查看指定评论的子评论。
|
||||
|
||||
覆盖 OpenAPI:
|
||||
|
||||
- `GET /api/v1/{owner}/{repo}/issues/{index}/journals.json`
|
||||
- `POST /api/v1/{owner}/{repo}/issues/{index}/journals.json`
|
||||
- `PATCH /api/v1/{owner}/{repo}/issues/{index}/journals/{id}.json`
|
||||
- `DELETE /api/v1/{owner}/{repo}/issues/{index}/journals/{id}.json`
|
||||
- `GET /api/v1/{owner}/{repo}/issues/{index}/journals/{id}/children_journals.json`
|
||||
|
||||
## PR review comments
|
||||
|
||||
新增:
|
||||
|
||||
- `pr +review-comments`:查看 PR Review 行评论,支持 review/state/path/parent/keyword 等过滤。
|
||||
- `pr +review-comment`:创建 PR Review 行评论,支持 `comment` / `problem` 类型和 `--dry-run`。
|
||||
- `pr +review-comment-update`:更新 Review 评论内容、commit 或状态,支持 `--dry-run`。
|
||||
- `pr +review-comment-delete`:删除 Review 评论,支持 `--dry-run`。
|
||||
|
||||
覆盖 OpenAPI:
|
||||
|
||||
- `GET /api/v1/{owner}/{repo}/pulls/{index}/journals.json`
|
||||
- `POST /api/v1/{owner}/{repo}/pulls/{index}/journals.json`
|
||||
- `PUT /api/v1/{owner}/{repo}/pulls/{index}/journals/{id}.json`
|
||||
- `DELETE /api/v1/{owner}/{repo}/pulls/{index}/journals/{id}.json`
|
||||
|
||||
## 安全设计
|
||||
|
||||
- 所有写入/删除评论的命令支持 `--dry-run`,先输出 method/path/body,不直接改远端数据。
|
||||
- PR Review 行评论创建支持 `--diff-json` / `--diff-file`,复杂 diff payload 由用户或 Agent 明确传入,避免 CLI 猜测 line diff。
|
||||
- 参数校验覆盖 Issue journal category、PR review comment type/state、布尔查询参数、正整数 ID 和 JSON diff。
|
||||
|
||||
## 测试
|
||||
|
||||
新增单元测试覆盖:
|
||||
|
||||
- Issue comments list/create/update/delete/children 的 method、path、query、payload。
|
||||
- PR review comments list/create/update/delete 的 method、path、query、payload。
|
||||
- dry-run 不触发 API。
|
||||
- 参数校验失败不触发 API。
|
||||
|
|
@ -272,29 +272,18 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
Description: tr.T("cmd.issue.comment.short"),
|
||||
Flags: appendIssueNumberFlags(
|
||||
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
|
||||
common.Flag{Name: "parent-id", Usage: "Parent comment ID when creating a child comment"},
|
||||
common.Flag{Name: "reply-id", Usage: "Reply target comment ID"},
|
||||
common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"},
|
||||
common.Flag{Name: "receivers", Short: "r", Usage: "Comma-separated @ receiver login names"},
|
||||
common.Flag{Name: "dry-run", Usage: "Preview the request body without creating the comment", Bool: true, Default: "false"},
|
||||
),
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"notes": body,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
Run: runIssueComment,
|
||||
},
|
||||
newIssueCommentsShortcut(),
|
||||
newIssueCommentUpdateShortcut(),
|
||||
newIssueCommentDeleteShortcut(),
|
||||
newIssueCommentChildrenShortcut(),
|
||||
{
|
||||
Name: "assigners",
|
||||
Description: "List issue assigners",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
|
|||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: args,
|
||||
Tr: i18n.Default(),
|
||||
}
|
||||
if ctx.Args == nil {
|
||||
ctx.Args = map[string]string{}
|
||||
|
|
@ -1127,3 +1129,180 @@ func TestNormalizeIssueStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCommentsListBuildsQuery(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("category"), "comment")
|
||||
assertEqual(t, query.Get("keyword"), "fixed")
|
||||
assertEqual(t, query.Get("sort_by"), "created_on")
|
||||
assertEqual(t, query.Get("sort_direction"), "desc")
|
||||
assertEqual(t, query.Get("page"), "2")
|
||||
assertEqual(t, query.Get("limit"), "5")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comments", map[string]string{
|
||||
"number": "42",
|
||||
"category": "comment",
|
||||
"keyword": "fixed",
|
||||
"sort-by": "created_on",
|
||||
"sort-direction": "desc",
|
||||
"page": "2",
|
||||
"limit": "5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comments shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCommentPostsPayloadWithOptions(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": 58, "notes": "please check"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comment", map[string]string{
|
||||
"number": "42",
|
||||
"body": "please check",
|
||||
"parent-id": "7",
|
||||
"reply-id": "8",
|
||||
"attachment-ids": "10,11,10",
|
||||
"receivers": "alice,bob,alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comment shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["notes"], "please check")
|
||||
assertEqual(t, payload["parent_id"], float64(7))
|
||||
assertEqual(t, payload["reply_id"], float64(8))
|
||||
assertNumberSlice(t, payload["attachment_ids"], []float64{10, 11})
|
||||
assertStringSlice(t, payload["receivers_login"], []string{"alice", "bob"})
|
||||
}
|
||||
|
||||
func TestIssueCommentUpdateDryRunDoesNotCallAPI(t *testing.T) {
|
||||
server := newIssueTestServer(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 := runShortcut(t, server, "comment-update", map[string]string{
|
||||
"number": "42",
|
||||
"comment-id": "58",
|
||||
"body": "updated",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comment-update dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCommentUpdatePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": 58, "notes": "updated"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comment-update", map[string]string{
|
||||
"number": "42",
|
||||
"comment-id": "58",
|
||||
"body": "updated",
|
||||
"attachment-ids": "12",
|
||||
"receivers": "alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comment-update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, payload["notes"], "updated")
|
||||
assertNumberSlice(t, payload["attachment_ids"], []float64{12})
|
||||
assertStringSlice(t, payload["receivers_login"], []string{"alice"})
|
||||
}
|
||||
|
||||
func TestIssueCommentDeleteUsesV1Endpoint(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comment-delete", map[string]string{
|
||||
"number": "42",
|
||||
"comment-id": "58",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comment-delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCommentChildrenBuildsQuery(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/58/children_journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("keyword"), "reply")
|
||||
assertEqual(t, query.Get("page"), "3")
|
||||
assertEqual(t, query.Get("limit"), "7")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comment-children", map[string]string{
|
||||
"number": "42",
|
||||
"comment-id": "58",
|
||||
"keyword": "reply",
|
||||
"page": "3",
|
||||
"limit": "7",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("comment-children shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueCommentRejectsInvalidCategory(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called for invalid category: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "comments", map[string]string{
|
||||
"number": "42",
|
||||
"category": "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid category to return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSlice(t *testing.T, got interface{}, want []string) {
|
||||
t.Helper()
|
||||
values, ok := got.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("got %T, want []interface{}", got)
|
||||
}
|
||||
if len(values) != len(want) {
|
||||
t.Fatalf("got %v, want %v", values, want)
|
||||
}
|
||||
for i, value := range values {
|
||||
if value != want[i] {
|
||||
t.Fatalf("got %v, want %v", values, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newIssueCommentsShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "comments",
|
||||
Description: "List issue comments and operation journals",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "category", Short: "c", Usage: "Journal category: all, comment, or operate", Default: "all"},
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "sort-by", Usage: "Sort field: created_on or updated_on"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: runIssueComments,
|
||||
}
|
||||
}
|
||||
|
||||
func newIssueCommentUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "comment-update",
|
||||
Description: "Update an issue comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "comment-id", Short: "i", Usage: "Issue comment/journal ID", Required: true},
|
||||
{Name: "body", Short: "b", Usage: "New comment body", Required: true},
|
||||
{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"},
|
||||
{Name: "receivers", Short: "r", Usage: "Comma-separated @ receiver login names"},
|
||||
{Name: "dry-run", Usage: "Preview the request body without updating the comment", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runIssueCommentUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func newIssueCommentDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "comment-delete",
|
||||
Description: "Delete an issue comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "comment-id", Short: "i", Usage: "Issue comment/journal ID", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the delete request without deleting the comment", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runIssueCommentDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func newIssueCommentChildrenShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "comment-children",
|
||||
Description: "List child comments for an issue comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
||||
{Name: "comment-id", Short: "i", Usage: "Parent issue comment/journal ID", Required: true},
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: runIssueCommentChildren,
|
||||
}
|
||||
}
|
||||
|
||||
func runIssueComments(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category, err := normalizeIssueJournalCategory(ctx.Arg("category"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
if category != "" {
|
||||
q.Set("category", category)
|
||||
}
|
||||
addStringQuery(q, "keyword", ctx.Arg("keyword"))
|
||||
addStringQuery(q, "sort_by", ctx.Arg("sort-by"))
|
||||
addStringQuery(q, "sort_direction", ctx.Arg("sort-direction"))
|
||||
q.Set("page", defaultValue(ctx.Arg("page"), "1"))
|
||||
q.Set("limit", defaultValue(ctx.Arg("limit"), "20"))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runIssueComment(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := issueCommentPayload(ctx, body, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := issueJournalPath(ctx, number)
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(issueJournalDryRun("create_comment", "POST", path, payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runIssueCommentUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := ctx.RequireArg("body")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := issueCommentPayload(ctx, body, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := issueJournalItemPath(ctx, number, commentID)
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(issueJournalDryRun("update_comment", "PATCH", path, payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runIssueCommentDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := issueJournalItemPath(ctx, number, commentID)
|
||||
if parseBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(issueJournalDryRun("delete_comment", "DELETE", path, nil))
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runIssueCommentChildren(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
number, err := issueNumberArg(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
addStringQuery(q, "keyword", ctx.Arg("keyword"))
|
||||
q.Set("page", defaultValue(ctx.Arg("page"), "1"))
|
||||
q.Set("limit", defaultValue(ctx.Arg("limit"), "20"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", issueJournalChildrenPath(ctx, number, commentID), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func issueCommentPayload(ctx *common.RuntimeContext, body string, includeReplyFields bool) (map[string]interface{}, error) {
|
||||
payload := map[string]interface{}{"notes": body}
|
||||
if includeReplyFields {
|
||||
if parentID := ctx.Arg("parent-id"); parentID != "" {
|
||||
id, err := parsePositiveIntValue(parentID, "parent-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["parent_id"] = id
|
||||
}
|
||||
if replyID := ctx.Arg("reply-id"); replyID != "" {
|
||||
id, err := parsePositiveIntValue(replyID, "reply-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["reply_id"] = id
|
||||
}
|
||||
}
|
||||
if value := ctx.Arg("attachment-ids"); value != "" {
|
||||
ids, err := parseIntCSV(value, "attachment-ids")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["attachment_ids"] = ids
|
||||
}
|
||||
if value := ctx.Arg("receivers"); value != "" {
|
||||
payload["receivers_login"] = parseStringCSV(value)
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func issueJournalPath(ctx *common.RuntimeContext, number string) string {
|
||||
return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number)
|
||||
}
|
||||
|
||||
func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string {
|
||||
return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), commentID)
|
||||
}
|
||||
|
||||
func issueJournalChildrenPath(ctx *common.RuntimeContext, number, commentID string) string {
|
||||
return fmt.Sprintf("%s/children_journals", issueJournalItemPath(ctx, number, commentID))
|
||||
}
|
||||
|
||||
func normalizeIssueJournalCategory(value string) (string, error) {
|
||||
category := strings.ToLower(strings.TrimSpace(value))
|
||||
if category == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch category {
|
||||
case "all", "comment", "operate":
|
||||
return category, nil
|
||||
default:
|
||||
return "", fmt.Errorf("invalid --category %q: use all, comment, or operate", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveIntValue(value, flagName string) (int, error) {
|
||||
id, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || id <= 0 {
|
||||
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func parseIntCSV(value, flagName string) ([]int, error) {
|
||||
values := make([]int, 0)
|
||||
seen := map[int]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
id, err := parsePositiveIntValue(part, flagName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
values = append(values, id)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parseStringCSV(value string) []string {
|
||||
values := make([]string, 0)
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || seen[part] {
|
||||
continue
|
||||
}
|
||||
seen[part] = true
|
||||
values = append(values, part)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func addStringQuery(q url.Values, key, value string) {
|
||||
if value != "" {
|
||||
q.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultValue(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func issueJournalDryRun(action, method, path string, payload map[string]interface{}) map[string]interface{} {
|
||||
data := map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"action": action,
|
||||
"method": method,
|
||||
"path": path,
|
||||
}
|
||||
if payload != nil {
|
||||
data["body"] = payload
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
package pr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var allowedPRReviewCommentTypes = map[string]bool{"comment": true, "problem": true}
|
||||
var allowedPRReviewCommentStates = map[string]bool{"opened": true, "resolved": true, "disabled": true}
|
||||
|
||||
func newPRReviewCommentsShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "review-comments",
|
||||
Description: "List pull request review comments",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "keyword", Short: "k", Usage: "Search keyword"},
|
||||
{Name: "review-id", Usage: "Filter by review ID"},
|
||||
{Name: "need-respond", Usage: "Filter by response requirement: true or false"},
|
||||
{Name: "state", Short: "s", Usage: "Filter state: opened, resolved, or disabled"},
|
||||
{Name: "parent-id", Usage: "Filter by parent comment ID"},
|
||||
{Name: "path", Usage: "Filter by file path"},
|
||||
{Name: "is-full", Usage: "Whether to include replies: true or false"},
|
||||
{Name: "sort-by", Usage: "Sort field: created_on or updated_on"},
|
||||
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
|
||||
},
|
||||
Run: runPRReviewComments,
|
||||
}
|
||||
}
|
||||
|
||||
func newPRReviewCommentShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "review-comment",
|
||||
Description: "Create a pull request review line comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "note", Short: "n", Usage: "Comment text", Required: true},
|
||||
{Name: "review-id", Usage: "Review ID", Required: true},
|
||||
{Name: "type", Short: "t", Usage: "Comment type: comment or problem", Default: "comment"},
|
||||
{Name: "commit", Short: "m", Usage: "Commit SHA for the review comment", Required: true},
|
||||
{Name: "line-code", Usage: "GitLink line_code value for the diff line", Required: true},
|
||||
{Name: "path", Short: "p", Usage: "File path for the review comment", Required: true},
|
||||
{Name: "parent-id", Usage: "Parent review comment ID when replying"},
|
||||
{Name: "diff-json", Usage: "Raw JSON diff object to include in the request body"},
|
||||
{Name: "diff-file", Usage: "Read JSON diff object from a file"},
|
||||
{Name: "dry-run", Usage: "Preview the request body without creating the review comment", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runPRReviewComment,
|
||||
}
|
||||
}
|
||||
|
||||
func newPRReviewCommentUpdateShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "review-comment-update",
|
||||
Description: "Update a pull request review comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "j", Usage: "Review comment/journal ID", Required: true},
|
||||
{Name: "note", Short: "n", Usage: "New comment text"},
|
||||
{Name: "commit", Short: "m", Usage: "Commit SHA"},
|
||||
{Name: "state", Short: "s", Usage: "Comment state: opened, resolved, or disabled"},
|
||||
{Name: "dry-run", Usage: "Preview the request body without updating the review comment", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runPRReviewCommentUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
func newPRReviewCommentDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "review-comment-delete",
|
||||
Description: "Delete a pull request review comment",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
||||
{Name: "comment-id", Short: "j", Usage: "Review comment/journal ID", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview the delete request without deleting the review comment", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runPRReviewCommentDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runPRReviewComments(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
addPRQuery(q, "keyword", ctx.Arg("keyword"))
|
||||
addPRQuery(q, "review_id", ctx.Arg("review-id"))
|
||||
if value := ctx.Arg("need-respond"); value != "" {
|
||||
if _, err := strconv.ParseBool(strings.TrimSpace(value)); err != nil {
|
||||
return fmt.Errorf("invalid --need-respond %q: use true or false", value)
|
||||
}
|
||||
q.Set("need_respond", value)
|
||||
}
|
||||
state, err := normalizePRReviewCommentState(ctx.Arg("state"), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addPRQuery(q, "state", state)
|
||||
addPRQuery(q, "parent_id", ctx.Arg("parent-id"))
|
||||
addPRQuery(q, "path", ctx.Arg("path"))
|
||||
if value := ctx.Arg("is-full"); value != "" {
|
||||
if _, err := strconv.ParseBool(strings.TrimSpace(value)); err != nil {
|
||||
return fmt.Errorf("invalid --is-full %q: use true or false", value)
|
||||
}
|
||||
q.Set("is_full", value)
|
||||
}
|
||||
addPRQuery(q, "sort_by", ctx.Arg("sort-by"))
|
||||
addPRQuery(q, "sort_direction", ctx.Arg("sort-direction"))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", prReviewJournalsPath(ctx, id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runPRReviewComment(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := prReviewCommentCreatePayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := prReviewJournalsPath(ctx, id)
|
||||
if parsePRBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(prJournalDryRun(ctx, id, "create_review_comment", "POST", path, payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runPRReviewCommentUpdate(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := prReviewCommentUpdatePayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := prReviewJournalItemPath(ctx, id, commentID)
|
||||
if parsePRBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(prJournalDryRun(ctx, id, "update_review_comment", "PUT", path, payload))
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func runPRReviewCommentDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commentID, err := ctx.RequireArg("comment-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := prReviewJournalItemPath(ctx, id, commentID)
|
||||
if parsePRBool(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(prJournalDryRun(ctx, id, "delete_review_comment", "DELETE", path, nil))
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func prReviewCommentCreatePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
note, err := ctx.RequireArg("note")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviewID, err := ctx.RequireArg("review-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commitID, err := ctx.RequireArg("commit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lineCode, err := ctx.RequireArg("line-code")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath, err := ctx.RequireArg("path")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
commentType, err := normalizePRReviewCommentType(defaultPRValue(ctx.Arg("type"), "comment"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"type": commentType,
|
||||
"note": note,
|
||||
"review_id": reviewID,
|
||||
"line_code": lineCode,
|
||||
"commit_id": commitID,
|
||||
"path": filePath,
|
||||
}
|
||||
if parentID := ctx.Arg("parent-id"); parentID != "" {
|
||||
id, err := parsePRPositiveInt(parentID, "parent-id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["parent_id"] = id
|
||||
}
|
||||
diff, err := parseJSONFromArgs(ctx.Arg("diff-json"), ctx.Arg("diff-file"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if diff != nil {
|
||||
payload["diff"] = diff
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func prReviewCommentUpdatePayload(ctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
payload := map[string]interface{}{}
|
||||
if note := ctx.Arg("note"); note != "" {
|
||||
payload["note"] = note
|
||||
}
|
||||
if commitID := ctx.Arg("commit"); commitID != "" {
|
||||
payload["commit_id"] = commitID
|
||||
}
|
||||
if state := ctx.Arg("state"); state != "" {
|
||||
normalized, err := normalizePRReviewCommentState(state, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload["state"] = normalized
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return nil, fmt.Errorf("at least one of --note, --commit, or --state is required")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func prReviewJournalsPath(ctx *common.RuntimeContext, id string) string {
|
||||
return prV1Path(ctx, id) + "/journals"
|
||||
}
|
||||
|
||||
func prReviewJournalItemPath(ctx *common.RuntimeContext, id, commentID string) string {
|
||||
return fmt.Sprintf("%s/%s", prReviewJournalsPath(ctx, id), commentID)
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentType(value string) (string, error) {
|
||||
commentType := strings.ToLower(strings.TrimSpace(value))
|
||||
if allowedPRReviewCommentTypes[commentType] {
|
||||
return commentType, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid --type %q: use comment or problem", value)
|
||||
}
|
||||
|
||||
func normalizePRReviewCommentState(value string, allowEmpty bool) (string, error) {
|
||||
state := strings.ToLower(strings.TrimSpace(value))
|
||||
if state == "" && allowEmpty {
|
||||
return "", nil
|
||||
}
|
||||
if allowedPRReviewCommentStates[state] {
|
||||
return state, nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid --state %q: use opened, resolved, or disabled", value)
|
||||
}
|
||||
|
||||
func parseJSONFromArgs(raw, file string) (interface{}, error) {
|
||||
if raw != "" && file != "" {
|
||||
return nil, fmt.Errorf("use either --diff-json or --diff-file, not both")
|
||||
}
|
||||
if file != "" {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read --diff-file: %w", err)
|
||||
}
|
||||
raw = string(data)
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var parsed interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return nil, fmt.Errorf("parse diff JSON: %w", err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parsePRPositiveInt(value, flagName string) (int, error) {
|
||||
id, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || id <= 0 {
|
||||
return 0, fmt.Errorf("invalid --%s %q: use a positive integer", flagName, value)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func addPRQuery(q url.Values, key, value string) {
|
||||
if value != "" {
|
||||
q.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPRValue(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parsePRBool(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
||||
func prJournalDryRun(ctx *common.RuntimeContext, id, action, method, path string, payload map[string]interface{}) map[string]interface{} {
|
||||
data := map[string]interface{}{
|
||||
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
"pull_request": id,
|
||||
"dry_run": true,
|
||||
"action": action,
|
||||
"method": method,
|
||||
"path": path,
|
||||
}
|
||||
if payload != nil {
|
||||
data["body"] = payload
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
|
@ -398,6 +398,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
newPRReviewCommentsShortcut(),
|
||||
newPRReviewCommentShortcut(),
|
||||
newPRReviewCommentUpdateShortcut(),
|
||||
newPRReviewCommentDeleteShortcut(),
|
||||
{
|
||||
Name: "comment",
|
||||
Description: tr.T("cmd.pr.comment.short"),
|
||||
|
|
|
|||
|
|
@ -531,3 +531,178 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
|
|||
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentsListBuildsQuery(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("keyword"), "todo")
|
||||
assertEqual(t, query.Get("review_id"), "10")
|
||||
assertEqual(t, query.Get("need_respond"), "true")
|
||||
assertEqual(t, query.Get("state"), "opened")
|
||||
assertEqual(t, query.Get("parent_id"), "200")
|
||||
assertEqual(t, query.Get("path"), "README.md")
|
||||
assertEqual(t, query.Get("is_full"), "true")
|
||||
assertEqual(t, query.Get("sort_by"), "created_on")
|
||||
assertEqual(t, query.Get("sort_direction"), "desc")
|
||||
writeJSON(t, w, map[string]interface{}{"total_count": 0, "journals": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comments", map[string]string{
|
||||
"id": "13",
|
||||
"keyword": "todo",
|
||||
"review-id": "10",
|
||||
"need-respond": "true",
|
||||
"state": "opened",
|
||||
"parent-id": "200",
|
||||
"path": "README.md",
|
||||
"is-full": "true",
|
||||
"sort-by": "created_on",
|
||||
"sort-direction": "desc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comments shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentPostsPayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/journals.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": 200, "note": "please fix"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "13",
|
||||
"note": "please fix",
|
||||
"review-id": "10",
|
||||
"type": "problem",
|
||||
"commit": "abc123",
|
||||
"line-code": "abc123_0_10",
|
||||
"path": "README.md",
|
||||
"parent-id": "199",
|
||||
"diff-json": `{"name":"README.md","addition":1}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment shortcut failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, payload["type"], "problem")
|
||||
assertEqual(t, payload["note"], "please fix")
|
||||
assertEqual(t, payload["review_id"], "10")
|
||||
assertEqual(t, payload["commit_id"], "abc123")
|
||||
assertEqual(t, payload["line_code"], "abc123_0_10")
|
||||
assertEqual(t, payload["path"], "README.md")
|
||||
assertEqual(t, payload["parent_id"], float64(199))
|
||||
diff, ok := payload["diff"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("diff type = %T, want map", payload["diff"])
|
||||
}
|
||||
assertEqual(t, diff["name"], "README.md")
|
||||
assertEqual(t, diff["addition"], float64(1))
|
||||
}
|
||||
|
||||
func TestPRReviewCommentDryRunDoesNotCallAPI(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called during dry-run: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "13",
|
||||
"note": "please fix",
|
||||
"review-id": "10",
|
||||
"commit": "abc123",
|
||||
"line-code": "abc123_0_10",
|
||||
"path": "README.md",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentUpdatePayload(t *testing.T) {
|
||||
var payload map[string]interface{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "PUT" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/200.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload = decodeJSON(t, r)
|
||||
writeJSON(t, w, map[string]interface{}{"id": 200, "note": "fixed", "state": "resolved"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment-update", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "200",
|
||||
"note": "fixed",
|
||||
"commit": "def456",
|
||||
"state": "resolved",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment-update shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, payload["note"], "fixed")
|
||||
assertEqual(t, payload["commit_id"], "def456")
|
||||
assertEqual(t, payload["state"], "resolved")
|
||||
}
|
||||
|
||||
func TestPRReviewCommentDeleteUsesV1Endpoint(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/pulls/13/journals/200.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runPRShortcut(t, server, "review-comment-delete", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "200",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("review-comment-delete shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRReviewCommentRejectsInvalidInputs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("server should not be called for invalid input: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runPRShortcut(t, server, "review-comment", map[string]string{
|
||||
"id": "13",
|
||||
"note": "please fix",
|
||||
"review-id": "10",
|
||||
"type": "todo",
|
||||
"commit": "abc123",
|
||||
"line-code": "abc123_0_10",
|
||||
"path": "README.md",
|
||||
}); err == nil {
|
||||
t.Fatal("expected invalid review comment type to fail")
|
||||
}
|
||||
|
||||
if err := runPRShortcut(t, server, "review-comment-update", map[string]string{
|
||||
"id": "13",
|
||||
"comment-id": "200",
|
||||
"state": "done",
|
||||
}); err == nil {
|
||||
t.Fatal("expected invalid review comment state to fail")
|
||||
}
|
||||
|
||||
if err := runPRShortcut(t, server, "review-comments", map[string]string{
|
||||
"id": "13",
|
||||
"need-respond": "maybe",
|
||||
}); err == nil {
|
||||
t.Fatal("expected invalid need-respond to fail")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ 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-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-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +comments` |
|
||||
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review`, `pr +review-comments` |
|
||||
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |
|
||||
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
|
||||
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: gitlink-issue
|
||||
version: 2.0.0
|
||||
description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。"
|
||||
description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,管理评论/操作记录。当用户需要操作 GitLink Issue 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
|
|
@ -26,7 +26,11 @@ metadata:
|
|||
| `issue +update` | 更新 Issue | 是 |
|
||||
| `issue +close` | 关闭 Issue | 是 |
|
||||
| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||
| `issue +comment` | 添加评论 | 是 |
|
||||
| `issue +comment` | 添加评论,支持回复、附件、@接收人和 dry-run | 是 |
|
||||
| `issue +comments` | 查看 Issue 评论和操作记录 | 否(公开项目) |
|
||||
| `issue +comment-update` | 更新 Issue 评论,支持 dry-run | 是 |
|
||||
| `issue +comment-delete` | 删除 Issue 评论,支持 dry-run | 是 |
|
||||
| `issue +comment-children` | 查看评论的子评论 | 否(公开项目) |
|
||||
| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) |
|
||||
| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) |
|
||||
| `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) |
|
||||
|
|
@ -63,6 +67,20 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv
|
|||
# 添加评论
|
||||
gitlink-cli issue +comment --number 4 --body "已修复,请验证"
|
||||
|
||||
# 回复评论 / @用户 / 携带附件(写操作先 dry-run)
|
||||
gitlink-cli issue +comment --number 4 --body "请 @alice 复核" --receivers alice --attachment-ids 10,11 --dry-run
|
||||
|
||||
# 查看评论和操作记录
|
||||
gitlink-cli issue +comments --number 4 --category all --page 1 --limit 20
|
||||
gitlink-cli issue +comments --number 4 --category comment --keyword "复现"
|
||||
|
||||
# 更新/删除评论(先 dry-run)
|
||||
gitlink-cli issue +comment-update --number 4 --comment-id 58 --body "更新后的评论" --dry-run
|
||||
gitlink-cli issue +comment-delete --number 4 --comment-id 58 --dry-run
|
||||
|
||||
# 查看子评论
|
||||
gitlink-cli issue +comment-children --number 4 --comment-id 58
|
||||
|
||||
# 查询 Issue 负责人
|
||||
gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice
|
||||
|
||||
|
|
@ -73,8 +91,7 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob
|
|||
## Raw API 补充
|
||||
|
||||
```bash
|
||||
# 获取 Issue 评论列表(使用 v1 API,按 issue number 查询)
|
||||
gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals
|
||||
# Issue 评论相关接口已由 issue +comments / +comment-update / +comment-delete / +comment-children 覆盖。
|
||||
|
||||
# 批量更新 Issue(仍使用旧版 API,需传数据库 ID)
|
||||
gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}'
|
||||
|
|
@ -103,7 +120,7 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"
|
|||
- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
|
||||
- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号
|
||||
- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID
|
||||
- Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作
|
||||
- Issue 操作和 Issue 评论操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作
|
||||
- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**
|
||||
- **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回)
|
||||
- v1 API 写操作必须使用 `access_token`(非 `token`)认证,CLI 已自动处理
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: gitlink-pr
|
||||
version: 1.0.0
|
||||
description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff 和 patchset/version。当用户需要操作 GitLink PR 时触发。"
|
||||
description: "Pull Request 管理:创建、查看、合并、关闭 PR,查看变更文件、Diff、patchset/version、审查和 Review 评论。当用户需要操作 GitLink PR 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
|
|
@ -31,7 +31,11 @@ metadata:
|
|||
| `pr +version-diff` | 查看指定 patchset/version diff | 否 |
|
||||
| `pr +reviews` | 查看 PR 审查记录,支持状态过滤 | 否 |
|
||||
| `pr +review` | 创建 PR 审查(支持 dry-run 预览) | 是 |
|
||||
| `pr +comment` | 给 PR 添加评论 | 是 |
|
||||
| `pr +review-comments` | 查看 PR Review 行评论 | 否 |
|
||||
| `pr +review-comment` | 创建 PR Review 行评论(支持 dry-run 预览) | 是 |
|
||||
| `pr +review-comment-update` | 更新 PR Review 行评论(支持 dry-run 预览) | 是 |
|
||||
| `pr +review-comment-delete` | 删除 PR Review 行评论(支持 dry-run 预览) | 是 |
|
||||
| `pr +comment` | 给 PR 添加普通评论 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -76,7 +80,17 @@ gitlink-cli pr +reviews --id 3 --status approved
|
|||
gitlink-cli pr +review --id 3 --status approved --content "LGTM, ready to merge"
|
||||
gitlink-cli pr +review --id 3 --status approved --content "LGTM" --dry-run
|
||||
|
||||
# 给 PR 添加评论
|
||||
# 查看 PR Review 行评论
|
||||
gitlink-cli pr +review-comments --id 3 --state opened --path README.md
|
||||
|
||||
# 创建 PR Review 行评论(需要 review-id、commit、line-code 和 path,先 dry-run)
|
||||
gitlink-cli pr +review-comment --id 3 --review-id 10 --commit abc123 --line-code abc123_0_10 --path README.md --note "这里需要修改" --type problem --dry-run
|
||||
|
||||
# 更新/解决/删除 PR Review 行评论(先 dry-run)
|
||||
gitlink-cli pr +review-comment-update --id 3 --comment-id 200 --state resolved --dry-run
|
||||
gitlink-cli pr +review-comment-delete --id 3 --comment-id 200 --dry-run
|
||||
|
||||
# 给 PR 添加普通评论
|
||||
gitlink-cli pr +comment --id 3 --body "LGTM, ready to merge"
|
||||
```
|
||||
|
||||
|
|
@ -148,6 +162,8 @@ gitlink-cli api POST /v1/:owner/:repo/pulls/:id/reviews --body '{"content":"LGTM
|
|||
gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews
|
||||
gitlink-cli api GET /v1/:owner/:repo/pulls/:id/reviews?status=approved
|
||||
|
||||
# PR Review 评论接口已由 pr +review-comments / +review-comment / +review-comment-update / +review-comment-delete 覆盖。
|
||||
|
||||
# 获取可用分支
|
||||
gitlink-cli api GET /:owner/:repo/pulls/get_branches
|
||||
|
||||
|
|
@ -172,3 +188,14 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff
|
|||
- `pr +list` 的 `--state` 参数会映射为 GitLink API 的 `status` 筛选;需要查看所有 PR 时传 `--state all`
|
||||
- PR 状态值:`pull_request_status` 0=open, 1=merged, 2=closed
|
||||
- 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或使用 `issue +comment` 留痕;不要用 Raw API 对 Issue 做不完整更新,否则可能清空 Issue 描述
|
||||
|
||||
|
||||
## PR Review 评论注意事项
|
||||
|
||||
- `pr +review-comment` 面向代码行级 Review 评论,不等同于 `pr +comment` 普通评论。
|
||||
- `--id` 是网页 URL `/pulls/N` 中的 PR 序号。
|
||||
- `--review-id` 可通过 `pr +reviews --id N` 获取。
|
||||
- `--line-code`、`--commit`、`--path` 应来自 GitLink diff / review 上下文;不确定时先用 `pr +files`、`pr +versions`、`pr +version-diff` 获取上下文。
|
||||
- `--type comment` 表示普通行评论,`--type problem` 表示需要回应的问题评论。
|
||||
- `--state opened|resolved|disabled` 用于筛选或更新 Review 评论状态。
|
||||
- 创建、更新、删除 Review 评论前必须先使用 `--dry-run` 向用户展示请求体。
|
||||
|
|
|
|||
Loading…
Reference in New Issue