forked from Gitlink/gitlink-cli
fix: preserve issue description on updates
This commit is contained in:
parent
7516ea8de9
commit
46aa383d76
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 前缀)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 描述
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` | 已内置处理 |
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
| 操作 | 必需字段 | 说明 |
|
||||
|------|----------|------|
|
||||
| Issue 创建 | `done_ratio: 0` | 数据库约束 |
|
||||
| Issue 更新 | `subject` | 即使只改状态也需要 |
|
||||
| Issue 更新 | 当前 `subject` 和 `description` | 即使只改状态也应保留,避免清空描述 |
|
||||
| Release 查看 | `version_id` | 不能用 tag_name |
|
||||
|
||||
### 端点前缀
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}'
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue