Skip to content

Commit bbc406b

Browse files
qdrivenclaude
andcommitted
add batch-issue command to create GitHub issues from folder of docs
New `spark git batch-issue <repo> -d <docs>` command reads markdown files from a folder and creates a GitHub issue for each one. Title is extracted from the first `# heading` or falls back to the filename. Supports --dry-run preview and --label flags. (Refs: #13) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 44626f2 commit bbc406b

File tree

7 files changed

+367
-1
lines changed

7 files changed

+367
-1
lines changed

cmd/git/batch_issue.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package git
2+
3+
import (
4+
"fmt"
5+
"spark/internal/github"
6+
"strings"
7+
8+
"github.com/pterm/pterm"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var (
13+
batchIssueDocs string
14+
batchIssueDryRun bool
15+
batchIssueLabels string
16+
)
17+
18+
var batchIssueCmd = &cobra.Command{
19+
Use: "batch-issue <repo>",
20+
Short: "Create GitHub issues from a folder of markdown documents",
21+
Long: `Create GitHub issues from a folder of markdown documents.
22+
23+
Each markdown file becomes one GitHub issue:
24+
- Title: extracted from the first "# heading" in the file, or the filename if no heading found
25+
- Body: the full content of the document
26+
27+
Requires gh CLI to be installed and authenticated.`,
28+
Args: cobra.ExactArgs(1),
29+
Example: ` spark git batch-issue variableway/spark-cli -d ./docs
30+
spark git batch-issue owner/repo -d ./issues --dry-run
31+
spark git batch-issue owner/repo -d ./docs --label "documentation"`,
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
repo := args[0]
34+
35+
issues, err := github.ReadDocsAsIssues(batchIssueDocs)
36+
if err != nil {
37+
return err
38+
}
39+
40+
var labels []string
41+
if batchIssueLabels != "" {
42+
labels = splitLabels(batchIssueLabels)
43+
}
44+
45+
fmt.Printf("Found %d documents in '%s'\n\n", len(issues), batchIssueDocs)
46+
47+
if batchIssueDryRun {
48+
pterm.Info.Println("Dry run mode - previewing issues:")
49+
fmt.Println()
50+
for i, issue := range issues {
51+
fmt.Printf("[%d/%d] %s\n", i+1, len(issues), issue.Title)
52+
fmt.Printf(" Labels: %v\n", labels)
53+
fmt.Printf(" Body length: %d chars\n\n", len(issue.Body))
54+
}
55+
return nil
56+
}
57+
58+
successCount := 0
59+
failCount := 0
60+
61+
for i, issue := range issues {
62+
fmt.Printf("[%d/%d] Creating issue: %s\n", i+1, len(issues), issue.Title)
63+
64+
if err := github.CreateIssue(repo, issue.Title, issue.Body, labels); err != nil {
65+
pterm.Error.Printf("Failed: %v\n", err)
66+
failCount++
67+
} else {
68+
pterm.Success.Printf("Created: %s\n", issue.Title)
69+
successCount++
70+
}
71+
}
72+
73+
fmt.Printf("\n--- Summary ---\n")
74+
fmt.Printf("Created: %d\n", successCount)
75+
fmt.Printf("Failed: %d\n", failCount)
76+
77+
return nil
78+
},
79+
}
80+
81+
func splitLabels(labelStr string) []string {
82+
var result []string
83+
for _, l := range strings.Split(labelStr, ",") {
84+
l = strings.TrimSpace(l)
85+
if l != "" {
86+
result = append(result, l)
87+
}
88+
}
89+
return result
90+
}
91+
92+
func init() {
93+
GitCmd.AddCommand(batchIssueCmd)
94+
95+
batchIssueCmd.Flags().StringVarP(&batchIssueDocs, "docs", "d", ".", "Folder containing markdown documents")
96+
batchIssueCmd.Flags().BoolVar(&batchIssueDryRun, "dry-run", false, "Preview issues without creating them")
97+
batchIssueCmd.Flags().StringVarP(&batchIssueLabels, "label", "l", "", "Labels to add to all issues (comma-separated)")
98+
}

cmd/git/git.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ This includes:
1515
- gitcode: Add Gitcode as remote
1616
- config: Configure git user for repository
1717
- url: Get repository remote URL
18-
- batch-clone: Clone all repos from a GitHub organization or user`,
18+
- batch-clone: Clone all repos from a GitHub organization or user
19+
- batch-issue: Create GitHub issues from a folder of markdown documents`,
1920
}
2021

2122
func init() {

docs/features/git.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ spark git batch-clone jackwener -o ./repos
5353
spark git update-org-status variableway --update-dot-github
5454
```
5555

56+
### 批量创建 Issue
57+
58+
从文件夹中的 Markdown 文档批量创建 GitHub Issue。每个文档对应一个 Issue,标题自动提取自文档的首行标题。
59+
60+
```bash
61+
# 从文档创建 Issue
62+
spark git batch-issue variableway/spark-cli -d ./docs
63+
64+
# 预览模式
65+
spark git batch-issue owner/repo -d ./issues --dry-run
66+
67+
# 添加标签
68+
spark git batch-issue owner/repo -d ./docs --label "documentation"
69+
```
70+
5671
## 使用参数
5772

5873
| 参数 | 说明 |

docs/usage/git.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ spark git config [--username --email] # 配置 Git 用户
1414
spark git url [repo-path] # 查看远程 URL
1515
spark git batch-clone <account> [-o <dir>] # 克隆用户/组织所有仓库
1616
spark git update-org-status <org> [--dry-run] # 更新组织 README
17+
spark git batch-issue <repo> [-d <docs-dir>] # 从文档批量创建 Issue
1718
```
1819

1920
---
@@ -168,6 +169,28 @@ spark git update-org-status variableway --update-dot-github # 直接推送
168169
spark git update-org-status variableway --section "My Projects"
169170
```
170171

172+
---
173+
174+
## spark git batch-issue
175+
176+
从文件夹中的 Markdown 文档批量创建 GitHub Issue。每个文档对应一个 Issue。
177+
178+
| 标志 | 简写 | 默认值 | 说明 |
179+
|------|------|--------|------|
180+
| `--docs` | `-d` | `.` | 包含 Markdown 文档的目录 |
181+
| `--dry-run` | | `false` | 预览不创建 |
182+
| `--label` | `-l` | | 为所有 Issue 添加标签(逗号分隔) |
183+
184+
**标题规则**
185+
- 优先使用文档中的第一个 `# 标题`
186+
- 无标题时使用文件名(去掉 `.md` 后缀)
187+
188+
```bash
189+
spark git batch-issue variableway/spark-cli -d ./docs
190+
spark git batch-issue owner/repo -d ./issues --dry-run
191+
spark git batch-issue owner/repo -d ./docs --label "documentation,enhancement"
192+
```
193+
171194
## 相关命令
172195

173196
- [Agent 配置](./agent.md)

internal/github/issue.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
type IssueInput struct {
12+
Title string
13+
Body string
14+
}
15+
16+
func CreateIssue(repo, title, body string, labels []string) error {
17+
args := []string{"issue", "create", "--repo", repo, "--title", title, "--body", body}
18+
for _, label := range labels {
19+
args = append(args, "--label", label)
20+
}
21+
22+
cmd := exec.Command("gh", args...)
23+
cmd.Stdout = os.Stdout
24+
cmd.Stderr = os.Stderr
25+
26+
if err := cmd.Run(); err != nil {
27+
return fmt.Errorf("failed to create issue '%s': %w", title, err)
28+
}
29+
30+
return nil
31+
}
32+
33+
func ReadDocsAsIssues(dir string) ([]IssueInput, error) {
34+
entries, err := os.ReadDir(dir)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to read directory '%s': %w", dir, err)
37+
}
38+
39+
var issues []IssueInput
40+
for _, entry := range entries {
41+
if entry.IsDir() {
42+
continue
43+
}
44+
45+
name := entry.Name()
46+
if !strings.HasSuffix(strings.ToLower(name), ".md") {
47+
continue
48+
}
49+
50+
filePath := filepath.Join(dir, name)
51+
content, err := os.ReadFile(filePath)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to read file '%s': %w", filePath, err)
54+
}
55+
56+
body := string(content)
57+
title := extractTitle(body, name)
58+
59+
issues = append(issues, IssueInput{
60+
Title: title,
61+
Body: body,
62+
})
63+
}
64+
65+
if len(issues) == 0 {
66+
return nil, fmt.Errorf("no markdown files found in '%s'", dir)
67+
}
68+
69+
return issues, nil
70+
}
71+
72+
func extractTitle(content, filename string) string {
73+
lines := strings.Split(content, "\n")
74+
for _, line := range lines {
75+
line = strings.TrimSpace(line)
76+
if strings.HasPrefix(line, "# ") {
77+
return strings.TrimSpace(strings.TrimPrefix(line, "#"))
78+
}
79+
}
80+
81+
ext := filepath.Ext(filename)
82+
return strings.TrimSuffix(filename, ext)
83+
}

internal/github/issue_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package github
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestExtractTitle(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
content string
13+
filename string
14+
want string
15+
}{
16+
{
17+
name: "extract from h1 heading",
18+
content: "# My Issue Title\n\nSome body content",
19+
filename: "issue.md",
20+
want: "My Issue Title",
21+
},
22+
{
23+
name: "fallback to filename without extension",
24+
content: "No heading here\nJust body text",
25+
filename: "my-issue.md",
26+
want: "my-issue",
27+
},
28+
{
29+
name: "skip h2 heading",
30+
content: "## This is h2\n# This is h1",
31+
filename: "test.md",
32+
want: "This is h1",
33+
},
34+
{
35+
name: "heading with leading spaces",
36+
content: " # Trimmed Title\nBody",
37+
filename: "test.md",
38+
want: "Trimmed Title",
39+
},
40+
{
41+
name: "empty file uses filename",
42+
content: "",
43+
filename: "empty-doc.md",
44+
want: "empty-doc",
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
got := extractTitle(tt.content, tt.filename)
51+
if got != tt.want {
52+
t.Errorf("extractTitle() = %q, want %q", got, tt.want)
53+
}
54+
})
55+
}
56+
}
57+
58+
func TestReadDocsAsIssues(t *testing.T) {
59+
dir := t.TempDir()
60+
61+
t.Run("reads markdown files as issues", func(t *testing.T) {
62+
os.WriteFile(filepath.Join(dir, "issue1.md"), []byte("# First Issue\n\nBody of first issue"), 0644)
63+
os.WriteFile(filepath.Join(dir, "issue2.md"), []byte("# Second Issue\n\nBody of second issue"), 0644)
64+
65+
issues, err := ReadDocsAsIssues(dir)
66+
if err != nil {
67+
t.Fatalf("ReadDocsAsIssues() error = %v", err)
68+
}
69+
if len(issues) != 2 {
70+
t.Fatalf("expected 2 issues, got %d", len(issues))
71+
}
72+
73+
titles := map[string]bool{issues[0].Title: true, issues[1].Title: true}
74+
if !titles["First Issue"] || !titles["Second Issue"] {
75+
t.Errorf("expected titles 'First Issue' and 'Second Issue', got %q and %q", issues[0].Title, issues[1].Title)
76+
}
77+
})
78+
79+
t.Run("returns error for empty directory", func(t *testing.T) {
80+
emptyDir := t.TempDir()
81+
_, err := ReadDocsAsIssues(emptyDir)
82+
if err == nil {
83+
t.Error("expected error for empty directory")
84+
}
85+
})
86+
87+
t.Run("ignores non-markdown files", func(t *testing.T) {
88+
mixedDir := t.TempDir()
89+
os.WriteFile(filepath.Join(mixedDir, "doc.md"), []byte("# Doc\nContent"), 0644)
90+
os.WriteFile(filepath.Join(mixedDir, "ignore.txt"), []byte("Not a doc"), 0644)
91+
92+
issues, err := ReadDocsAsIssues(mixedDir)
93+
if err != nil {
94+
t.Fatalf("ReadDocsAsIssues() error = %v", err)
95+
}
96+
if len(issues) != 1 {
97+
t.Fatalf("expected 1 issue, got %d", len(issues))
98+
}
99+
if issues[0].Title != "Doc" {
100+
t.Errorf("expected title 'Doc', got %q", issues[0].Title)
101+
}
102+
})
103+
}

0 commit comments

Comments
 (0)