Merge PR #57: feat(issue): support metadata fields

Resolved conflicts with i18n infrastructure from PR #95 and metadata
lookup shortcuts from PR #43.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wbtiger 2026-06-02 22:46:34 +08:00
commit 9843fc7ceb
7 changed files with 781 additions and 152 deletions

View File

@ -247,9 +247,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
# Create an issue with metadata
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
# 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
@ -278,6 +284,11 @@ gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
### Label Management
```bash

View File

@ -258,9 +258,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
# 创建带元数据的 Issue
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
# 更新 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
@ -289,6 +295,10 @@ gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
编号的兼容别名,不是数据库内部 ID。
### 标签管理
```bash

View File

@ -0,0 +1,24 @@
# Issue ID Alias
## Summary
`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept
`--id` / `-i` as a compatibility alias for `--number` / `-n`.
The alias uses the same project-level issue number shown in the web URL, for
example `issues/123`. It is not the global database ID.
`--number` remains the preferred flag and takes precedence when both flags are
provided.
## Examples
```bash
gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed"
```
## Submitter
Wang Yue

View File

@ -0,0 +1,36 @@
# Issue Metadata Fields
## Summary
`issue +create` and `issue +update` now support common GitLink Issue metadata fields.
When updating or closing an Issue, the shortcut also carries the current metadata
back to the API so unrelated fields are not reset by partial updates.
## Added flags
| Flag | API field |
|------|-----------|
| `--priority-id` | `priority_id` |
| `--tag-ids` | `issue_tag_ids` |
| `--assigner-ids` | `assigner_ids` |
| `--branch` | `branch_name` |
| `--start-date` | `start_date` |
| `--due-date` | `due_date` |
`issue +create --label` is also mapped as a single tag ID for backward compatibility.
## Examples
```bash
gitlink-cli issue +create --owner Gitlink --repo forgeplus \
--title "Bug: login failed" \
--priority-id 3 \
--tag-ids 4,5 \
--assigner-ids 7
gitlink-cli issue +update --owner Gitlink --repo forgeplus \
--number 123 \
--priority-id 4 \
--branch bugfix/login \
--due-date 2026-06-15
```

View File

@ -19,6 +19,13 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
type existingIssue struct {
Subject string
Description string
StatusID interface{}
PriorityID interface{}
TagIDs []interface{}
AssignerIDs []interface{}
BranchName string
StartDate string
DueDate string
}
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
@ -60,6 +67,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")},
{Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")},
{Name: "label", Usage: tr.T("flag.issue.label")},
{Name: "priority-id", Usage: "Priority ID", Default: "2"},
{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
{Name: "branch", Usage: "Linked branch name"},
{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -84,6 +97,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
@ -94,10 +110,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{
Name: "view",
Description: tr.T("cmd.issue.view.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number")},
{Name: "id", Usage: "Alias for --number; uses the issue number from the web URL"},
},
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -116,14 +129,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{
Name: "close",
Description: tr.T("cmd.issue.close.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
},
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
@ -135,8 +146,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 5, // 5 = closed
}
preserveIssueMetadata(body, current)
body["status_id"] = 5 // 5 = closed
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -147,25 +159,30 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{
Name: "update",
Description: tr.T("cmd.issue.update.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
},
Flags: appendIssueNumberFlags(
common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
common.Flag{Name: "priority-id", Usage: "New priority ID"},
common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
common.Flag{Name: "branch", Usage: "Linked branch name"},
common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
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")
if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) {
return fmt.Errorf("at least one update field is required")
}
current, err := fetchExistingIssue(ctx, number)
@ -177,6 +194,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
"subject": current.Subject,
"description": current.Description,
}
preserveIssueMetadata(body, current)
if t := ctx.Arg("title"); t != "" {
body["subject"] = t
}
@ -190,6 +208,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
}
body["status_id"] = statusID
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -200,15 +221,14 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
{
Name: "comment",
Description: tr.T("cmd.issue.comment.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
},
Flags: appendIssueNumberFlags(
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
@ -353,6 +373,27 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
return i18n.Default()
}
func issueNumberFlags() []common.Flag {
return []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"},
{Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"},
}
}
func appendIssueNumberFlags(flags ...common.Flag) []common.Flag {
return append(issueNumberFlags(), flags...)
}
func issueNumberArg(ctx *common.RuntimeContext) (string, error) {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number, nil
}
if id := strings.TrimSpace(ctx.Arg("id")); id != "" {
return id, nil
}
return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)")
}
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
// "id" to "database_id" so the user-facing output uses the project-level
// issue number, not the global database primary key.
@ -400,9 +441,76 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
return &existingIssue{
Subject: subject,
Description: description,
StatusID: nestedIssueID(issueData, "status"),
PriorityID: nestedIssueID(issueData, "priority"),
TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"),
AssignerIDs: issueObjectIDs(issueData, "assigners"),
BranchName: stringField(issueData, "branch_name"),
StartDate: stringField(issueData, "start_date"),
DueDate: stringField(issueData, "due_date"),
}, nil
}
func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) {
if issue.StatusID != nil {
body["status_id"] = issue.StatusID
}
if issue.PriorityID != nil {
body["priority_id"] = issue.PriorityID
}
if len(issue.TagIDs) > 0 {
body["issue_tag_ids"] = issue.TagIDs
}
if len(issue.AssignerIDs) > 0 {
body["assigner_ids"] = issue.AssignerIDs
}
if issue.BranchName != "" {
body["branch_name"] = issue.BranchName
}
if issue.StartDate != "" {
body["start_date"] = issue.StartDate
}
if issue.DueDate != "" {
body["due_date"] = issue.DueDate
}
}
func nestedIssueID(data map[string]interface{}, key string) interface{} {
item, ok := data[key].(map[string]interface{})
if !ok {
return nil
}
return item["id"]
}
func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} {
for _, key := range keys {
items, ok := data[key].([]interface{})
if !ok {
continue
}
ids := make([]interface{}, 0, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
if id, ok := obj["id"]; ok {
ids = append(ids, id)
}
}
if len(ids) > 0 {
return ids
}
}
return nil
}
func stringField(data map[string]interface{}, key string) string {
value, _ := data[key].(string)
return value
}
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
@ -417,12 +525,77 @@ func normalizeIssueStatus(state string) (interface{}, error) {
}
}
func issueNumberArg(ctx *common.RuntimeContext) (string, error) {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number, nil
func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} {
if ctx.Arg(name) != "" {
return true
}
}
if id := strings.TrimSpace(ctx.Arg("id")); id != "" {
return id, nil
}
return "", fmt.Errorf("required flag --number (or --id alias) not set")
return false
}
func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error {
if priority := ctx.Arg("priority-id"); priority != "" {
priorityID, err := parseIssueID(priority, "priority-id")
if err != nil {
return err
}
body["priority_id"] = priorityID
}
tagIDs := ctx.Arg("tag-ids")
if label := ctx.Arg("label"); label != "" {
if tagIDs != "" {
return fmt.Errorf("--label cannot be used with --tag-ids")
}
tagIDs = label
}
if tagIDs != "" {
ids, err := parseIssueIDList(tagIDs, "tag-ids")
if err != nil {
return err
}
body["issue_tag_ids"] = ids
}
if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" {
ids, err := parseIssueIDList(assignerIDs, "assigner-ids")
if err != nil {
return err
}
body["assigner_ids"] = ids
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch_name"] = branch
}
if startDate := ctx.Arg("start-date"); startDate != "" {
body["start_date"] = startDate
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
body["due_date"] = dueDate
}
return nil
}
func parseIssueIDList(value, flagName string) ([]int, error) {
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
for _, part := range parts {
id, err := parseIssueID(part, flagName)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func parseIssueID(value, flagName string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, fmt.Errorf("--%s contains an empty ID", flagName)
}
id, err := strconv.Atoi(trimmed)
if err != nil || id <= 0 {
return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName)
}
return id, nil
}

File diff suppressed because it is too large Load Diff

View File

@ -82,15 +82,23 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"
| gitlink-cli 参数 | GitLink API 字段 | 说明 |
|------------------|-----------------|------|
| `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) |
| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID |
| `--title` | `subject` | Issue 标题 |
| `--body` | `description` | Issue 描述 |
| `--assignee` | `assigned_to_id` | 指派人 ID |
| `--milestone` | `fixed_version_id` | 里程碑 ID |
| `--state` | `status_id` | 状态open=1closed=5也可直接传数字 ID |
| `--priority-id` | `priority_id` | 优先级 ID |
| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 |
| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 |
| `--branch` | `branch_name` | 关联分支 |
| `--start-date` | `start_date` | 开始日期 |
| `--due-date` | `due_date` | 截止日期 |
## API 注意事项
- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号
- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID
- Issue 操作使用 v1 API`/api/v1/`),支持按 Issue 编号查询和操作
- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**