fix: preserve issue description on updates

This commit is contained in:
Zhang Jinnan 2026-04-28 08:43:51 +08:00
parent 7516ea8de9
commit 46aa383d76
10 changed files with 245 additions and 26 deletions

View File

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

View File

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

View File

@ -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=1closed=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 前缀)

View File

@ -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": <current subject>, "status_id": 5 }
Body: { "subject": <current subject>, "description": <current description>, "status_id": 5 }
```
## Workflow

View File

@ -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=1closed=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.

View File

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

View File

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

View File

@ -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 <version_id>` 正常工作 | 已验证通过 |
| 分支操作需要 `/v1/` 前缀 | 分支的 create/delete/list 端点使用 `/v1/:owner/:repo/branches` | 已内置处理 |

View File

@ -55,7 +55,7 @@
| 操作 | 必需字段 | 说明 |
|------|----------|------|
| Issue 创建 | `done_ratio: 0` | 数据库约束 |
| Issue 更新 | `subject` | 即使只改状态也需要 |
| Issue 更新 | 当前 `subject``description` | 即使只改状态也应保留,避免清空描述 |
| Release 查看 | `version_id` | 不能用 tag_name |
### 端点前缀

View File

@ -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
}'
```