forked from Gitlink/gitlink-cli
320 lines
8.2 KiB
Go
320 lines
8.2 KiB
Go
package rules
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
|
)
|
|
|
|
const mitLicense = `MIT License
|
|
|
|
Copyright (c) %d
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
of this software and associated documentation files (the "Software"), to deal
|
|
in the Software without restriction, including without limitation the rights
|
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
copies of the Software, and to permit persons to whom the Software is
|
|
furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in all
|
|
copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
SOFTWARE.
|
|
`
|
|
|
|
const gitignoreGo = `# Binaries
|
|
*.exe
|
|
*.exe~
|
|
*.dll
|
|
*.so
|
|
*.dylib
|
|
bin/
|
|
dist/
|
|
|
|
# Test binary
|
|
*.test
|
|
|
|
# Output of go coverage
|
|
*.out
|
|
|
|
# Go workspace
|
|
go.work
|
|
|
|
# IDE
|
|
.idea/
|
|
.vscode/
|
|
*.swp
|
|
*.swo
|
|
|
|
# OS
|
|
.DS_Store
|
|
Thumbs.db
|
|
|
|
# Env
|
|
.env
|
|
.env.local
|
|
`
|
|
|
|
type labelDef struct{ Name, Color string }
|
|
|
|
var defaultLabels = []labelDef{
|
|
{"bug", "#d73a4a"},
|
|
{"enhancement", "#a2eeef"},
|
|
{"documentation", "#0075ca"},
|
|
{"good first issue", "#7057ff"},
|
|
{"question", "#d876e3"},
|
|
{"duplicate", "#cfd3d7"},
|
|
{"wontfix", "#ffffff"},
|
|
}
|
|
|
|
var defaultIssueTemplates = []struct {
|
|
Title string
|
|
Body string
|
|
}{
|
|
{
|
|
"项目初始化",
|
|
"# 项目初始化\n\n完成仓库基本配置和代码框架搭建。\n\n- [ ] README 文档\n- [ ] LICENSE 文件\n- [ ] .gitignore 配置\n- [ ] CI/CD 流水线",
|
|
},
|
|
{
|
|
"代码框架搭建",
|
|
"# 代码框架搭建\n\n搭建项目基本目录结构和核心代码框架。\n\n- [ ] 项目目录结构\n- [ ] 入口文件\n- [ ] 核心模块骨架",
|
|
},
|
|
{
|
|
"首个版本发布 v0.1.0",
|
|
"# v0.1.0 发布准备\n\n完成首个可用版本的开发和测试。\n\n- [ ] 核心功能开发\n- [ ] 单元测试\n- [ ] 发布说明",
|
|
},
|
|
}
|
|
|
|
// InitScaffoldRule creates a new repository and initializes it with standard
|
|
// project scaffolding based on a user-supplied description.
|
|
//
|
|
// Upstream keys used:
|
|
//
|
|
// _desc — project description (generates repo name + README)
|
|
// _repo — explicit repo name (overrides auto-generation)
|
|
// _owner — repository owner
|
|
func InitScaffoldRule(upstream map[string]interface{}, stepName string) (*workflow.AIResponse, error) {
|
|
desc := str(upstream, "_desc")
|
|
owner := str(upstream, "_owner")
|
|
repo := str(upstream, "_repo")
|
|
|
|
if owner == "" {
|
|
return nil, fmt.Errorf("missing _owner in upstream")
|
|
}
|
|
|
|
// Generate repo name from description if not explicitly provided.
|
|
if repo == "" && desc != "" {
|
|
repo = deriveRepoName(desc)
|
|
}
|
|
if repo == "" {
|
|
repo = "new-project"
|
|
}
|
|
|
|
// Generate README from description.
|
|
readme := fmt.Sprintf("# %s\n\n%s\n", repo, desc)
|
|
if desc == "" {
|
|
readme = fmt.Sprintf("# %s\n\nProject description.\n", repo)
|
|
}
|
|
|
|
projectDesc := desc
|
|
if projectDesc == "" {
|
|
projectDesc = repo
|
|
}
|
|
|
|
var actions []workflow.AIAction
|
|
|
|
// 1. Create the repository via CLI (handles user_id resolution internally).
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "cli", Module: "repo", Command: "+create",
|
|
Args: map[string]string{
|
|
"name": repo,
|
|
"description": projectDesc,
|
|
},
|
|
})
|
|
|
|
// 2. README.md
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{base}/create_file",
|
|
Body: map[string]interface{}{
|
|
"filepath": "README.md",
|
|
"content": base64.StdEncoding.EncodeToString([]byte(readme)),
|
|
"message": "docs: add README.md",
|
|
"branch": "master",
|
|
},
|
|
})
|
|
|
|
// 3. LICENSE (MIT)
|
|
license := fmt.Sprintf(mitLicense, time.Now().Year())
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{base}/create_file",
|
|
Body: map[string]interface{}{
|
|
"filepath": "LICENSE",
|
|
"content": base64.StdEncoding.EncodeToString([]byte(license)),
|
|
"message": "docs: add MIT LICENSE",
|
|
"branch": "master",
|
|
},
|
|
})
|
|
|
|
// 4. .gitignore
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{base}/create_file",
|
|
Body: map[string]interface{}{
|
|
"filepath": ".gitignore",
|
|
"content": base64.StdEncoding.EncodeToString([]byte(gitignoreGo)),
|
|
"message": "chore: add .gitignore",
|
|
"branch": "master",
|
|
},
|
|
})
|
|
|
|
// 5. Default labels.
|
|
for _, l := range defaultLabels {
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{v1}/issue_tags",
|
|
Body: map[string]interface{}{
|
|
"name": l.Name,
|
|
"color": l.Color,
|
|
},
|
|
})
|
|
}
|
|
|
|
// 6. Initial milestone: v0.1.0, due 3 months from now.
|
|
due := time.Now().AddDate(0, 3, 0).Format("2006-01-02")
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{v1}/milestones",
|
|
Body: map[string]interface{}{
|
|
"name": "v0.1.0",
|
|
"description": "首个版本发布",
|
|
"effective_date": due,
|
|
},
|
|
})
|
|
|
|
// 7. Initial issues.
|
|
for _, tpl := range defaultIssueTemplates {
|
|
actions = append(actions, workflow.AIAction{
|
|
Type: "api", Method: "POST",
|
|
Path: "{v1}/issues",
|
|
Body: map[string]interface{}{
|
|
"subject": tpl.Title,
|
|
"description": tpl.Body,
|
|
"status_id": 1, // open
|
|
"priority_id": 2, // normal
|
|
"done_ratio": 0,
|
|
},
|
|
})
|
|
}
|
|
|
|
analysis := map[string]interface{}{
|
|
"repo": fmt.Sprintf("%s/%s", owner, repo),
|
|
"description": projectDesc,
|
|
"files_created": 3,
|
|
"labels_created": len(defaultLabels),
|
|
"milestones_created": 1,
|
|
"issues_created": len(defaultIssueTemplates),
|
|
"summary": fmt.Sprintf(
|
|
"仓库 %s/%s 创建完成:%d 个文件,%d 个标签,%d 个里程碑,%d 个 Issue",
|
|
owner, repo, 3, len(defaultLabels), 1, len(defaultIssueTemplates),
|
|
),
|
|
}
|
|
|
|
return &workflow.AIResponse{Analysis: analysis, Actions: actions}, nil
|
|
}
|
|
|
|
// deriveRepoName generates a short ASCII repo name from a description.
|
|
// GitLink only allows ASCII letters, digits, underscores, hyphens, and dots
|
|
// in repo identifiers — Chinese characters are rejected by the API.
|
|
func deriveRepoName(desc string) string {
|
|
// 1. Extract English words first (handles mixed Chinese-English descriptions).
|
|
engWords := extractEnglishWords(desc)
|
|
if len(engWords) >= 2 {
|
|
return strings.ToLower(strings.Join(engWords[:min(3, len(engWords))], "-"))
|
|
}
|
|
if len(engWords) == 1 {
|
|
return strings.ToLower(engWords[0])
|
|
}
|
|
|
|
// 2. Strip non-ASCII characters, then sanitize what remains.
|
|
ascii := strings.Map(func(r rune) rune {
|
|
if r < 128 {
|
|
return r
|
|
}
|
|
return -1
|
|
}, desc)
|
|
ascii = strings.TrimSpace(ascii)
|
|
ascii = strings.ToLower(ascii)
|
|
ascii = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(ascii, "-")
|
|
ascii = strings.Trim(ascii, "-")
|
|
|
|
if len(ascii) >= 2 {
|
|
if len(ascii) > 30 {
|
|
ascii = ascii[:30]
|
|
}
|
|
return ascii
|
|
}
|
|
|
|
// 3. No usable ASCII content — use a stable hash-based name.
|
|
h := md5.Sum([]byte(desc))
|
|
return fmt.Sprintf("project-%x", h[:4])
|
|
}
|
|
|
|
func extractEnglishWords(s string) []string {
|
|
re := regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9]*`)
|
|
words := re.FindAllString(s, -1)
|
|
// Filter out common stop words.
|
|
stop := map[string]bool{
|
|
"a": true, "an": true, "the": true, "is": true, "are": true,
|
|
"for": true, "of": true, "to": true, "in": true, "and": true,
|
|
"or": true, "it": true, "on": true, "at": true, "by": true,
|
|
}
|
|
var result []string
|
|
for _, w := range words {
|
|
if len(w) >= 2 && !stop[strings.ToLower(w)] {
|
|
result = append(result, w)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func extractChinese(s string) string {
|
|
var result []rune
|
|
for _, r := range s {
|
|
if unicode.Is(unicode.Han, r) {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
if len(result) == 0 {
|
|
return ""
|
|
}
|
|
// Return at most 10 Chinese characters.
|
|
if len(result) > 10 {
|
|
result = result[:10]
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
_ = utf8.RuneLen('a') // ensure unicode/utf8 import is used
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|