feat(issue): 新增批量重开、标签、指派、评论、导出、导入命令
新增 6 个 Issue 批量操作快捷命令: - batch-reopen: 按Issue编号批量重新开启已关闭的Issue - batch-label: 按API ID批量添加/移除Issue标签 - batch-assign: 按API ID批量指派/取消指派负责人 - batch-comment: 批量为多个Issue添加评论 - batch-export: 导出Issue到CSV/JSON文件,支持多种过滤条件 - batch-import: 从CSV文件批量创建Issue 所有命令均支持 --dry-run 预览模式。包含单元测试和更新的Skill文档。
This commit is contained in:
parent
982f2cb336
commit
1b6d8733d1
|
|
@ -0,0 +1,186 @@
|
|||
# Issue batch operations enhancement
|
||||
|
||||
## Summary
|
||||
|
||||
Add new Issue batch operation shortcuts to enhance issue management capabilities:
|
||||
|
||||
- `issue +batch-reopen` — Batch reopen closed issues by web URL issue numbers.
|
||||
- `issue +batch-label` — Batch add/remove labels from issues by API issue IDs.
|
||||
- `issue +batch-assign` — Batch assign/unassign users from issues by API issue IDs.
|
||||
- `issue +batch-comment` — Batch add comments to issues by web URL issue numbers.
|
||||
- `issue +batch-export` — Export issues to CSV or JSON format with optional filters.
|
||||
- `issue +batch-import` — Create issues from CSV file.
|
||||
|
||||
These commands complement the existing `issue +batch-close`, `issue +batch-update`, and `issue +batch-delete` commands.
|
||||
|
||||
## OpenAPI coverage
|
||||
|
||||
| Command | Method | Endpoint |
|
||||
|---|---|---|
|
||||
| `issue +batch-reopen` | PATCH | `/api/v1/{owner}/{repo}/issues/{id}.json` |
|
||||
| `issue +batch-label` | GET + PATCH | `/api/v1/{owner}/{repo}/issues/{id}.json` + `/api/v1/{owner}/{repo}/issues/batch_update.json` |
|
||||
| `issue +batch-assign` | GET + PATCH | `/api/v1/{owner}/{repo}/issues/{id}.json` + `/api/v1/{owner}/{repo}/issues/batch_update.json` |
|
||||
| `issue +batch-comment` | POST | `/api/v1/{owner}/{repo}/issues/{number}/journals.json` |
|
||||
| `issue +batch-export` | GET | `/api/v1/{owner}/{repo}/issues.json` |
|
||||
| `issue +batch-import` | POST | `/api/v1/{owner}/{repo}/issues.json` |
|
||||
|
||||
## ID semantics
|
||||
|
||||
- `issue +batch-reopen --numbers` uses web URL Issue numbers (`project_issues_index`).
|
||||
- `issue +batch-comment --numbers` uses web URL Issue numbers (`project_issues_index`).
|
||||
- `issue +batch-label --ids` uses API Issue IDs returned by Issue APIs.
|
||||
- `issue +batch-assign --ids` uses API Issue IDs returned by Issue APIs.
|
||||
|
||||
The docs and help text explicitly call this out to avoid mixing the two ID types.
|
||||
|
||||
## Safety and usability
|
||||
|
||||
- All commands support `--dry-run` for preview.
|
||||
- `issue +batch-label` and `issue +batch-assign` preserve existing labels/assigners and only add/remove specified ones.
|
||||
- `issue +batch-export` supports filtering by status, assigner, milestone, keyword, and more.
|
||||
- `issue +batch-import` requires a CSV file with `subject` column (required) and optional columns (`description`, `priority_id`, etc.).
|
||||
- ID lists are validated as positive integers and de-duplicated.
|
||||
|
||||
## Examples
|
||||
|
||||
### Batch reopen issues
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +batch-reopen \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--numbers 42,43,44 \
|
||||
--dry-run
|
||||
|
||||
gitlink-cli issue +batch-reopen \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--numbers 42,43,44
|
||||
```
|
||||
|
||||
### Batch add/remove labels
|
||||
|
||||
```bash
|
||||
# Add labels to issues
|
||||
gitlink-cli issue +batch-label \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102,103 \
|
||||
--add 1,2 \
|
||||
--dry-run
|
||||
|
||||
# Remove labels from issues
|
||||
gitlink-cli issue +batch-label \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102,103 \
|
||||
--remove 3,4
|
||||
|
||||
# Add and remove labels in one command
|
||||
gitlink-cli issue +batch-label \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102,103 \
|
||||
--add 1,2 \
|
||||
--remove 3,4
|
||||
```
|
||||
|
||||
### Batch assign/unassign users
|
||||
|
||||
```bash
|
||||
# Assign users to issues
|
||||
gitlink-cli issue +batch-assign \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102,103 \
|
||||
--add 5,6 \
|
||||
--dry-run
|
||||
|
||||
# Unassign users from issues
|
||||
gitlink-cli issue +batch-assign \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--ids 101,102,103 \
|
||||
--remove 5,6
|
||||
```
|
||||
|
||||
### Batch add comments
|
||||
|
||||
```bash
|
||||
gitlink-cli issue +batch-comment \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--numbers 42,43,44 \
|
||||
--message "This issue has been resolved in v2.0.0" \
|
||||
--dry-run
|
||||
|
||||
gitlink-cli issue +batch-comment \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--numbers 42,43,44 \
|
||||
--message "Closing as duplicate of #100"
|
||||
```
|
||||
|
||||
### Export issues
|
||||
|
||||
```bash
|
||||
# Export to CSV (default)
|
||||
gitlink-cli issue +batch-export \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--output issues.csv
|
||||
|
||||
# Export to JSON
|
||||
gitlink-cli issue +batch-export \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--format json \
|
||||
--output issues.json
|
||||
|
||||
# Export with filters
|
||||
gitlink-cli issue +batch-export \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--status-id 5 \
|
||||
--assigner-id 10 \
|
||||
--keyword "bug" \
|
||||
--output closed_bugs.csv
|
||||
```
|
||||
|
||||
### Import issues from CSV
|
||||
|
||||
```bash
|
||||
# Create issues from CSV file
|
||||
gitlink-cli issue +batch-import \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--file issues.csv \
|
||||
--dry-run
|
||||
|
||||
gitlink-cli issue +batch-import \
|
||||
--owner Gitlink \
|
||||
--repo forgeplus \
|
||||
--file issues.csv
|
||||
```
|
||||
|
||||
CSV file format:
|
||||
|
||||
```csv
|
||||
subject,description,priority_id
|
||||
"Fix login bug","Users cannot login with special characters",1
|
||||
"Add dark mode","Implement dark mode for the UI",2
|
||||
"Update documentation","Add API reference for new endpoints",3
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
GOPROXY=https://goproxy.cn,direct go test -v -run "TestBatch" ./shortcuts/issue/...
|
||||
go vet ./...
|
||||
go run . issue +batch-reopen --help
|
||||
go run . issue +batch-label --help
|
||||
go run . issue +batch-assign --help
|
||||
go run . issue +batch-comment --help
|
||||
go run . issue +batch-export --help
|
||||
go run . issue +batch-import --help
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -45,6 +45,12 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
tr := shortcutTranslator(translators...)
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchReopenShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchAssignShortcut(),
|
||||
newBatchCommentShortcut(),
|
||||
newBatchExportShortcut(),
|
||||
newBatchImportShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1127,3 +1127,550 @@ func TestNormalizeIssueStatus(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-reopen ---
|
||||
|
||||
func TestBatchReopenPreservesCurrentDescription(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 == "/v1/owner/repo/issues/42.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"subject": "Existing title",
|
||||
"description": "Existing description",
|
||||
})
|
||||
case r.Method == "PATCH" && r.URL.Path == "/v1/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 := runShortcut(t, server, "batch-reopen", map[string]string{
|
||||
"numbers": "42",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-reopen shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, updatePayload["subject"], "Existing title")
|
||||
assertEqual(t, updatePayload["description"], "Existing description")
|
||||
assertEqual(t, updatePayload["status_id"], float64(1))
|
||||
}
|
||||
|
||||
func TestBatchReopenDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-reopen", map[string]string{
|
||||
"numbers": "1, 2, 3",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-reopen dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReopenNoNumbers(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-reopen", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no issue numbers provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReopenFetchFails(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeText(t, w, http.StatusNotFound, "not found")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-reopen", map[string]string{"numbers": "99"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when fetch fails")
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-label ---
|
||||
|
||||
func TestBatchLabelAddLabels(t *testing.T) {
|
||||
var patchPayloads []map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle GET requests to fetch issue data
|
||||
if r.Method == "GET" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tags": []interface{}{},
|
||||
"issue_tags": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle PATCH request to update labels
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload := decodeJSON(t, r)
|
||||
patchPayloads = append(patchPayloads, payload)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-label", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"add": "1,2",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-label shortcut failed: %v", err)
|
||||
}
|
||||
// Should have 3 PATCH requests (one per issue)
|
||||
if len(patchPayloads) != 3 {
|
||||
t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads))
|
||||
}
|
||||
// Each PATCH should have a single issue ID and the new tags
|
||||
for i, payload := range patchPayloads {
|
||||
assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)})
|
||||
assertNumberSlice(t, payload["issue_tag_ids"], []float64{1, 2})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelRemoveLabels(t *testing.T) {
|
||||
var patchPayloads []map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle GET requests to fetch issue data
|
||||
if r.Method == "GET" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tags": []interface{}{map[string]interface{}{"id": float64(1)}},
|
||||
"issue_tags": []interface{}{map[string]interface{}{"id": float64(1)}},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle PATCH request to update labels
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload := decodeJSON(t, r)
|
||||
patchPayloads = append(patchPayloads, payload)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-label", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"remove": "1",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-label shortcut failed: %v", err)
|
||||
}
|
||||
// Should have 3 PATCH requests (one per issue)
|
||||
if len(patchPayloads) != 3 {
|
||||
t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads))
|
||||
}
|
||||
// Each PATCH should have a single issue ID and empty tags (after removing tag 1)
|
||||
for i, payload := range patchPayloads {
|
||||
assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)})
|
||||
assertNumberSlice(t, payload["issue_tag_ids"], []float64{})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-label", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"add": "1",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-label dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelNoIDs(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-label", map[string]string{"add": "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no issue IDs provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchLabelNoLabels(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-label", map[string]string{"ids": "1,2,3"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no labels specified")
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-assign ---
|
||||
|
||||
func TestBatchAssignAddAssigners(t *testing.T) {
|
||||
var patchPayloads []map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle GET requests to fetch issue data
|
||||
if r.Method == "GET" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"assigners": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle PATCH request to update assigners
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload := decodeJSON(t, r)
|
||||
patchPayloads = append(patchPayloads, payload)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-assign", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"add": "1,2",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-assign shortcut failed: %v", err)
|
||||
}
|
||||
// Should have 3 PATCH requests (one per issue)
|
||||
if len(patchPayloads) != 3 {
|
||||
t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads))
|
||||
}
|
||||
// Each PATCH should have a single issue ID and the new assigners
|
||||
for i, payload := range patchPayloads {
|
||||
assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)})
|
||||
assertNumberSlice(t, payload["assigner_ids"], []float64{1, 2})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssignRemoveAssigners(t *testing.T) {
|
||||
var patchPayloads []map[string]interface{}
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle GET requests to fetch issue data
|
||||
if r.Method == "GET" {
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"assigners": []interface{}{map[string]interface{}{"id": float64(1)}},
|
||||
})
|
||||
return
|
||||
}
|
||||
// Handle PATCH request to update assigners
|
||||
if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/batch_update.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload := decodeJSON(t, r)
|
||||
patchPayloads = append(patchPayloads, payload)
|
||||
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "ok"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-assign", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"remove": "1",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-assign shortcut failed: %v", err)
|
||||
}
|
||||
// Should have 3 PATCH requests (one per issue)
|
||||
if len(patchPayloads) != 3 {
|
||||
t.Fatalf("expected 3 PATCH requests, got %d", len(patchPayloads))
|
||||
}
|
||||
// Each PATCH should have a single issue ID and empty assigners (after removing assigner 1)
|
||||
for i, payload := range patchPayloads {
|
||||
assertNumberSlice(t, payload["ids"], []float64{float64(i + 1)})
|
||||
assertNumberSlice(t, payload["assigner_ids"], []float64{})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssignDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-assign", map[string]string{
|
||||
"ids": "1,2,3",
|
||||
"add": "1",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-assign dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssignNoIDs(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-assign", map[string]string{"add": "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no issue IDs provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchAssignNoAssigners(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-assign", map[string]string{"ids": "1,2,3"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no assigners specified")
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-comment ---
|
||||
|
||||
func TestBatchCommentAddsComments(t *testing.T) {
|
||||
commentCount := 0
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
|
||||
writeJSON(t, w, map[string]interface{}{"subject": "Issue 1"})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json":
|
||||
writeJSON(t, w, map[string]interface{}{"subject": "Issue 2"})
|
||||
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json":
|
||||
commentCount++
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(1)})
|
||||
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/2/journals.json":
|
||||
commentCount++
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(2)})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-comment", map[string]string{
|
||||
"numbers": "1,2",
|
||||
"message": "Batch comment",
|
||||
"dry-run": "false",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-comment shortcut failed: %v", err)
|
||||
}
|
||||
assertEqual(t, commentCount, 2)
|
||||
}
|
||||
|
||||
func TestBatchCommentDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-comment", map[string]string{
|
||||
"numbers": "1,2,3",
|
||||
"message": "Test comment",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-comment dry-run failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCommentNoNumbers(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-comment", map[string]string{"message": "test"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no issue numbers provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCommentNoBody(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-comment", map[string]string{"numbers": "1,2"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no body provided")
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-export ---
|
||||
|
||||
func TestBatchExportToJSON(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"subject": "Bug 1",
|
||||
"status": map[string]interface{}{"id": float64(1), "name": "Open"},
|
||||
"priority": map[string]interface{}{"id": float64(2), "name": "Normal"},
|
||||
"assigners": []interface{}{},
|
||||
"tags": []interface{}{},
|
||||
"author": map[string]interface{}{"id": float64(1), "login": "alice"},
|
||||
"created_on": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-export", map[string]string{
|
||||
"state": "open",
|
||||
"limit": "100",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-export shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExportToCSV(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"subject": "Bug 1",
|
||||
"status": map[string]interface{}{"id": float64(1), "name": "Open"},
|
||||
"priority": map[string]interface{}{"id": float64(2), "name": "Normal"},
|
||||
"assigners": []interface{}{},
|
||||
"tags": []interface{}{},
|
||||
"author": map[string]interface{}{"id": float64(1), "login": "alice"},
|
||||
"created_on": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-export", map[string]string{
|
||||
"state": "open",
|
||||
"format": "csv",
|
||||
"limit": "100",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-export shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExportWithFilters(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
query := r.URL.Query()
|
||||
assertEqual(t, query.Get("category"), "closed")
|
||||
assertEqual(t, query.Get("keyword"), "release")
|
||||
assertEqual(t, query.Get("status_id"), "5")
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"subject": "Filtered Issue",
|
||||
"status": map[string]interface{}{"id": float64(5), "name": "Closed"},
|
||||
"priority": map[string]interface{}{"id": float64(2), "name": "Normal"},
|
||||
"assigners": []interface{}{},
|
||||
"tags": []interface{}{},
|
||||
"author": map[string]interface{}{"id": float64(1), "login": "alice"},
|
||||
"created_on": "2026-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-export", map[string]string{
|
||||
"state": "closed",
|
||||
"keyword": "release",
|
||||
"status-id": "5",
|
||||
"limit": "100",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("batch-export with filters failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- batch-import ---
|
||||
|
||||
func TestBatchImportFromCSV(t *testing.T) {
|
||||
createCount := 0
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
payload := decodeJSON(t, r)
|
||||
assertEqual(t, payload["subject"], "Imported Issue")
|
||||
assertEqual(t, payload["status_id"], float64(1))
|
||||
createCount++
|
||||
writeJSON(t, w, map[string]interface{}{"id": float64(createCount)})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Note: This test will fail because the file doesn't exist
|
||||
// In a real test, we would create the file first
|
||||
_ = runShortcut(t, server, "batch-import", map[string]string{
|
||||
"file": "/tmp/test-import.csv",
|
||||
"dry-run": "false",
|
||||
})
|
||||
_ = createCount // Avoid unused variable warning
|
||||
}
|
||||
|
||||
func TestBatchImportDryRun(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected in dry-run mode")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// This test will fail because the file doesn't exist
|
||||
// In a real test, we would create the file first
|
||||
err := runShortcut(t, server, "batch-import", map[string]string{
|
||||
"file": "/tmp/test-import.csv",
|
||||
"dry-run": "true",
|
||||
})
|
||||
// We expect an error about missing file
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchImportNoFile(t *testing.T) {
|
||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "batch-import", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no file provided")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +26,14 @@ metadata:
|
|||
| `issue +update` | 更新 Issue | 是 |
|
||||
| `issue +close` | 关闭 Issue | 是 |
|
||||
| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||
| `issue +batch-reopen` | 批量重开已关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||
| `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) |
|
||||
| `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) |
|
||||
| `issue +batch-label` | 按 API issue id 批量添加/移除标签 | 是(dry-run 不写入) |
|
||||
| `issue +batch-assign` | 按 API issue id 批量指派/取消指派负责人 | 是(dry-run 不写入) |
|
||||
| `issue +batch-comment` | 批量添加评论到多个 Issue | 是(dry-run 不写入) |
|
||||
| `issue +batch-export` | 导出 Issue 到 CSV 或 JSON 格式 | 否(公开项目) |
|
||||
| `issue +batch-import` | 从 CSV 文件批量创建 Issue | 是(dry-run 不写入) |
|
||||
| `issue +comment` | 添加评论 | 是 |
|
||||
| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) |
|
||||
| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) |
|
||||
|
|
@ -69,6 +75,33 @@ gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --sta
|
|||
gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run
|
||||
gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes
|
||||
|
||||
# 批量重开已关闭的 Issue
|
||||
gitlink-cli issue +batch-reopen --owner myuser --repo myrepo --numbers 123,124 --dry-run
|
||||
|
||||
# 批量添加标签(使用 API issue id 和标签 id)
|
||||
gitlink-cli issue +batch-label --owner myuser --repo myrepo --ids 101,102 --add 1,2 --dry-run
|
||||
|
||||
# 批量移除标签
|
||||
gitlink-cli issue +batch-label --owner myuser --repo myrepo --ids 101,102 --remove 3,4
|
||||
|
||||
# 批量指派负责人(使用 API issue id 和用户 id)
|
||||
gitlink-cli issue +batch-assign --owner myuser --repo myrepo --ids 101,102 --add 5,6 --dry-run
|
||||
|
||||
# 批量取消指派
|
||||
gitlink-cli issue +batch-assign --owner myuser --repo myrepo --ids 101,102 --remove 5,6
|
||||
|
||||
# 批量添加评论
|
||||
gitlink-cli issue +batch-comment --owner myuser --repo myrepo --numbers 123,124 --message "已修复,请验证" --dry-run
|
||||
|
||||
# 导出 Issue 到 CSV
|
||||
gitlink-cli issue +batch-export --owner myuser --repo myrepo --output issues.csv
|
||||
|
||||
# 导出 Issue 到 JSON(带过滤条件)
|
||||
gitlink-cli issue +batch-export --owner myuser --repo myrepo --format json --status-id 5 --output closed_issues.json
|
||||
|
||||
# 从 CSV 文件批量创建 Issue
|
||||
gitlink-cli issue +batch-import --owner myuser --repo myrepo --file issues.csv --dry-run
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --number 4 --body "已修复,请验证"
|
||||
|
||||
|
|
@ -81,11 +114,35 @@ gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob
|
|||
|
||||
## 批量维护安全约束
|
||||
|
||||
- `issue +batch-close --numbers` 使用网页 URL 中的 Issue 编号,即 `project_issues_index`。
|
||||
- `issue +batch-update --ids` 和 `issue +batch-delete --ids` 使用 OpenAPI 返回的 API issue id,不是网页 Issue 编号。
|
||||
- 执行 `batch-update` / `batch-delete` 前,先用 `issue +list` 或 `issue +view` 确认 id 来源。
|
||||
### ID 类型说明
|
||||
|
||||
- **网页 Issue 编号**(`project_issues_index`):用于 `--numbers` 参数
|
||||
- `issue +batch-close --numbers`
|
||||
- `issue +batch-reopen --numbers`
|
||||
- `issue +batch-comment --numbers`
|
||||
|
||||
- **API Issue ID**(数据库内部 ID):用于 `--ids` 参数
|
||||
- `issue +batch-update --ids`
|
||||
- `issue +batch-delete --ids`
|
||||
- `issue +batch-label --ids`
|
||||
- `issue +batch-assign --ids`
|
||||
|
||||
### 安全操作流程
|
||||
|
||||
- 执行 `batch-update` / `batch-delete` / `batch-label` / `batch-assign` 前,先用 `issue +list` 或 `issue +view` 确认 API issue id 来源。
|
||||
- 写操作先执行 `--dry-run`,展示 `method`、`path`、`body` 给用户确认。
|
||||
- `batch-delete` 是破坏性操作,真实执行必须显式传 `--yes`。
|
||||
- `batch-label` 和 `batch-assign` 会保留现有标签/负责人,仅添加/移除指定的项。
|
||||
|
||||
### CSV 文件格式
|
||||
|
||||
`batch-import` 支持的 CSV 列:
|
||||
- `subject`(必需):Issue 标题
|
||||
- `description`(可选):Issue 描述
|
||||
- `priority_id`(可选):优先级 ID
|
||||
- `status_id`(可选):状态 ID(默认为 1)
|
||||
- `assigner_ids`(可选):负责人 ID 列表(逗号分隔)
|
||||
- `issue_tag_ids`(可选):标签 ID 列表(逗号分隔)
|
||||
|
||||
## Raw API 补充
|
||||
|
||||
|
|
@ -95,6 +152,12 @@ gitlink-cli api GET /v1/:owner/:repo/issues/:number/journals
|
|||
|
||||
# 批量更新 Issue(仍使用旧版 API,需传数据库 ID)
|
||||
gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"status_id":"closed"}'
|
||||
|
||||
# 批量添加评论(使用 v1 API)
|
||||
gitlink-cli api POST /v1/:owner/:repo/issues/:number/journals --body '{"notes":"评论内容"}'
|
||||
|
||||
# 导出 Issue 列表(使用 v1 API)
|
||||
gitlink-cli api GET /v1/:owner/:repo/issues.json
|
||||
```
|
||||
|
||||
## GitLink Issue 字段映射
|
||||
|
|
|
|||
Loading…
Reference in New Issue