diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b23800b..9eb4ca3 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -3,10 +3,17 @@ package issue import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +type existingIssue struct { + Subject string + Description string +} + func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { @@ -108,20 +115,15 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - // First fetch the issue to get current title - getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), id), nil) + current, err := fetchExistingIssue(ctx, id) if err != nil { return err } - issueData, ok := getEnv.Data.(map[string]interface{}) - if !ok { - return fmt.Errorf("failed to parse issue data") - } - subject, _ := issueData["subject"].(string) body := map[string]interface{}{ - "subject": subject, - "status_id": 5, // 5 = closed + "subject": current.Subject, + "description": current.Description, + "status_id": 5, // 5 = closed } env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), id), body) if err != nil { @@ -137,7 +139,7 @@ func Shortcuts() []*common.Shortcut { {Name: "id", Short: "i", Usage: "Issue ID", Required: true}, {Name: "title", Short: "t", Usage: "New title"}, {Name: "body", Short: "b", Usage: "New description"}, - {Name: "state", Short: "s", Usage: "New state: open, closed"}, + {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -147,7 +149,22 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - body := map[string]interface{}{} + title := ctx.Arg("title") + description := ctx.Arg("body") + state := ctx.Arg("state") + if title == "" && description == "" && state == "" { + return fmt.Errorf("at least one of --title, --body, or --state is required") + } + + current, err := fetchExistingIssue(ctx, id) + if err != nil { + return err + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + } if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -155,7 +172,11 @@ func Shortcuts() []*common.Shortcut { body["description"] = b } if s := ctx.Arg("state"); s != "" { - body["status_id"] = s + statusID, err := normalizeIssueStatus(s) + if err != nil { + return err + } + body["status_id"] = statusID } env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), id), body) if err != nil { @@ -195,3 +216,37 @@ func Shortcuts() []*common.Shortcut { }, } } + +func fetchExistingIssue(ctx *common.RuntimeContext, id string) (*existingIssue, error) { + getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", ctx.RepoPath(), id), nil) + if err != nil { + return nil, err + } + issueData, ok := getEnv.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to parse issue data") + } + subject, _ := issueData["subject"].(string) + if subject == "" { + return nil, fmt.Errorf("failed to parse issue subject") + } + description, _ := issueData["description"].(string) + return &existingIssue{ + Subject: subject, + Description: description, + }, nil +} + +func normalizeIssueStatus(state string) (interface{}, error) { + switch strings.ToLower(strings.TrimSpace(state)) { + case "open": + return 1, nil + case "closed": + return 5, nil + default: + if id, err := strconv.Atoi(state); err == nil { + return id, nil + } + return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) + } +} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go new file mode 100644 index 0000000..0df2bd8 --- /dev/null +++ b/shortcuts/issue/issue_test.go @@ -0,0 +1,157 @@ +package issue + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestIssueClosePreservesCurrentDescription(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runIssueShortcut(t, server, "close", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(5)) +} + +func TestIssueUpdatePreservesCurrentDescriptionWhenChangingTitleAndState(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runIssueShortcut(t, server, "update", map[string]string{ + "id": "42", + "title": "New title", + "state": "closed", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(5)) +} + +func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PUT" && r.URL.Path == "/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runIssueShortcut(t, server, "update", map[string]string{ + "id": "42", + "body": "New description", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "New description") +} + +func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findIssueShortcut(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 findIssueShortcut(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 newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +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 got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index bec9654..0e16d10 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -70,11 +70,11 @@ gitlink-cli api POST /issues/:issue_id/claims | `--body` | `description` | Issue 描述 | | `--assignee` | `assigned_to_id` | 指派人 ID | | `--milestone` | `fixed_version_id` | 里程碑 ID | -| `--state` | `status_id` | 状态(5=关闭) | +| `--state` | `status_id` | 状态(open=1,closed=5,也可直接传数字 ID) | ## API 注意事项 - **创建 Issue 时必须包含 `done_ratio: 0`**,否则数据库报错(CLI 已自动处理) -- **更新/关闭 Issue 时必须包含 `subject` 字段**,即使只修改状态(CLI 已自动处理) -- 使用 Raw API 操作 Issue 时需手动添加这些字段 +- **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回) +- 使用 Raw API 操作 Issue 时需先 `GET issue`,再把当前 `subject`、`description` 与要修改的字段一起提交,避免清空描述 - Issue 评论路径为 `/issues/:id/journals`(不带 owner/repo 前缀) diff --git a/skills/gitlink-issue/references/gitlink-issue-close.md b/skills/gitlink-issue/references/gitlink-issue-close.md index 65c02ed..9f70067 100644 --- a/skills/gitlink-issue/references/gitlink-issue-close.md +++ b/skills/gitlink-issue/references/gitlink-issue-close.md @@ -2,7 +2,7 @@ > **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 -Close an issue. Automatically fetches the current issue subject and sets `status_id=5` (closed). +Close an issue. Automatically fetches the current issue subject and description, then sets `status_id=5` (closed) without clearing the description. ## 命令 @@ -25,14 +25,14 @@ gitlink-cli issue +close -i 42 The command performs two API calls: -1. **Fetch** the issue to get the current `subject`: +1. **Fetch** the issue to get the current `subject` and `description`: ``` GET /{owner}/{repo}/issues/{id} ``` -2. **Update** the issue with `status_id=5`: +2. **Update** the issue with `status_id=5`, preserving the current description: ``` PUT /{owner}/{repo}/issues/{id} - Body: { "subject": , "status_id": 5 } + Body: { "subject": , "description": , "status_id": 5 } ``` ## Workflow diff --git a/skills/gitlink-issue/references/gitlink-issue-update.md b/skills/gitlink-issue/references/gitlink-issue-update.md index 86a4e3b..12c5df7 100644 --- a/skills/gitlink-issue/references/gitlink-issue-update.md +++ b/skills/gitlink-issue/references/gitlink-issue-update.md @@ -4,6 +4,8 @@ Update an existing issue's title, description, or state. +The shortcut reads the current issue first and preserves existing `subject` and `description` unless the user explicitly changes them. This prevents partial updates from clearing the issue description. + ## 命令 ```bash @@ -24,7 +26,7 @@ gitlink-cli issue +update -i 42 -t "Revised title" -s closed | `--id, -i` | **是** | Issue ID | | `--title, -t` | 否 | 新标题(映射为 API 字段 `subject`) | | `--body, -b` | 否 | 新描述(映射为 API 字段 `description`) | -| `--state, -s` | 否 | 新状态: `open`、`closed`(映射为 API 字段 `status_id`) | +| `--state, -s` | 否 | 新状态: `open`、`closed`,或数字状态 ID(映射为 API 字段 `status_id`;open=1,closed=5) | | `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | | `--repo` | 否 | 仓库名称(自动从 git remote 解析) | | `--format` | 否 | 输出格式: `json`/`table`/`yaml` | @@ -34,7 +36,7 @@ gitlink-cli issue +update -i 42 -t "Revised title" -s closed ``` PUT /{owner}/{repo}/issues/{id} -Body: { "subject": title, "description": body, "status_id": state } +Body: { "subject": current_or_new_title, "description": current_or_new_body, "status_id": state } ``` ## Workflow @@ -43,6 +45,8 @@ Body: { "subject": title, "description": body, "status_id": state } 2. **Execute** `gitlink-cli issue +update -i {id} -t "..." -b "..."`. 3. **Report** the updated issue details to the user. +When using Raw API instead of the shortcut, fetch the issue first and include the current `subject` and `description` in the update payload. + > [!CAUTION] > This is a **Write Operation** -- confirm user intent before executing. diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index ff3e34b..3c5e054 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -129,3 +129,4 @@ gitlink-cli api GET /:owner/:repo/pulls/get_branches - `pr +diff` 实际调用 `/pulls/:id/files` 端点,返回变更文件列表和 diff 内容 - `pr +list` 的 `--state` 参数(open/merged/closed)仅影响统计计数,API 返回的列表可能包含所有状态的 PR - PR 状态值:`pull_request_status` 0=open, 1=merged, 2=closed +- 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或使用 `issue +comment` 留痕;不要用 Raw API 对 Issue 做不完整更新,否则可能清空 Issue 描述 diff --git a/skills/gitlink-pr/references/gitlink-pr-create.md b/skills/gitlink-pr/references/gitlink-pr-create.md index a0eac4f..95500e0 100644 --- a/skills/gitlink-pr/references/gitlink-pr-create.md +++ b/skills/gitlink-pr/references/gitlink-pr-create.md @@ -93,6 +93,7 @@ gitlink-cli api PUT /:owner/:repo/update_file --body '{ - GitLink 默认主分支为 `master`(非 `main`),`--base` 默认值为 `master` - `create_file` 的 `content` 字段**必须 base64 编码**,不编码会返回 "文件已存在" 错误 - 创建成功后返回的 `pull_request_id` 用于后续 view/merge/close 操作 +- 关联已有 Issue 时,把 Issue 编号或 URL 写入 PR `--body`,或用 `issue +comment` 在 Issue 下补充 PR 链接;不要为了关联 PR 直接用 Raw API 更新 Issue 描述 ## References diff --git a/skills/gitlink-shared/SKILL.md b/skills/gitlink-shared/SKILL.md index 39f012d..d1f9d33 100644 --- a/skills/gitlink-shared/SKILL.md +++ b/skills/gitlink-shared/SKILL.md @@ -99,7 +99,7 @@ gitlink-cli auth login | 问题 | 说明 | 影响 | |------|------|------| | Issue 创建需要 `done_ratio` | 创建 Issue 时必须包含 `done_ratio: 0`,否则数据库报错 | `issue +create` 已内置处理 | -| Issue 更新需要 `subject` | 任何 Issue 更新(包括只改状态)都必须带上 `subject` 字段 | `issue +close` 已内置处理,Raw API 需手动处理 | +| Issue 更新需保留 `subject`/`description` | 任何 Issue 更新(包括只改状态)都应带上当前 `subject` 和 `description`,否则可能清空描述 | `issue +update`/`issue +close` 已内置处理,Raw API 需先 GET 再提交 | | Release 查看需要 `version_id` | `release +view` 必须用 `version_id`(从 `release +list` 获取),不能用 tag_name | tag_name 会返回 HTML 页面 | | Release 删除需要 `version_id` | `release +delete -i ` 正常工作 | 已验证通过 | | 分支操作需要 `/v1/` 前缀 | 分支的 create/delete/list 端点使用 `/v1/:owner/:repo/branches` | 已内置处理 | diff --git a/skills/gitlink-shared/references/api-reference.md b/skills/gitlink-shared/references/api-reference.md index 51331ed..13c6d84 100644 --- a/skills/gitlink-shared/references/api-reference.md +++ b/skills/gitlink-shared/references/api-reference.md @@ -55,7 +55,7 @@ | 操作 | 必需字段 | 说明 | |------|----------|------| | Issue 创建 | `done_ratio: 0` | 数据库约束 | -| Issue 更新 | `subject` | 即使只改状态也需要 | +| Issue 更新 | 当前 `subject` 和 `description` | 即使只改状态也应保留,避免清空描述 | | Release 查看 | `version_id` | 不能用 tag_name | ### 端点前缀 diff --git a/skills/gitlink-shared/references/troubleshooting.md b/skills/gitlink-shared/references/troubleshooting.md index 3532443..79040fe 100644 --- a/skills/gitlink-shared/references/troubleshooting.md +++ b/skills/gitlink-shared/references/troubleshooting.md @@ -65,18 +65,19 @@ gitlink-cli api POST /:owner/:repo/issues --body '{ }' ``` -### Q: Issue 关闭失败 - "验证失败: 标题不能为空" +### Q: Issue 关闭失败 - "验证失败: 标题不能为空" 或描述被清空 -**原因**: 更新 Issue 时缺少 `subject` 字段 +**原因**: 更新 Issue 时缺少当前 `subject`,或没有保留当前 `description` **解决**: ```bash # 使用 issue +close shortcut(已自动处理) gitlink-cli issue +close -i 123 -# 或使用 Raw API 时添加 subject +# 或使用 Raw API 时先 GET 当前 Issue,再添加 subject 和 description gitlink-cli api PUT /:owner/:repo/issues/123 --body '{ "subject": "当前标题", + "description": "当前描述", "status_id": 5 }' ```