forked from Gitlink/gitlink-cli
394 lines
10 KiB
Go
394 lines
10 KiB
Go
package onboard
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// marker is a unique string embedded in welcome comments to detect existing ones.
|
||
const marker = "<!-- gitlink-cli:onboard -->"
|
||
|
||
// tagCache caches tag name→id mappings per owner/repo.
|
||
var tagCache sync.Map
|
||
|
||
// Shortcuts returns the onboarding shortcut group.
|
||
func Shortcuts() []*common.Shortcut {
|
||
return []*common.Shortcut{
|
||
{
|
||
Name: "welcome",
|
||
Description: "Add welcome comments to specific issues or tag-matched issues",
|
||
Flags: []common.Flag{
|
||
{Name: "issues", Short: "i", Usage: "Comma-separated issue numbers (e.g. 1,3,7)"},
|
||
{Name: "tag", Short: "t", Usage: "Tag name to match (comma-separated)", Default: "good first issue,help wanted"},
|
||
{Name: "template", Usage: "Custom welcome message template ({login}, {number}, {subject}, {description})"},
|
||
{Name: "force", Short: "f", Usage: "Force re-add even if already commented", Bool: true},
|
||
},
|
||
DryRun: true,
|
||
DryRunHint: dryRunHint,
|
||
Run: runWelcome,
|
||
},
|
||
}
|
||
}
|
||
|
||
func dryRunHint(ctx *common.RuntimeContext) (string, error) {
|
||
if issues := ctx.Arg("issues"); issues != "" {
|
||
return fmt.Sprintf("将为 issue #%s 添加新人引导评论", issues), nil
|
||
}
|
||
tag := ctx.Arg("tag")
|
||
if tag == "" {
|
||
tag = "good first issue,help wanted"
|
||
}
|
||
return fmt.Sprintf("将为所有 [%s] 标签的 issue 添加新人引导评论", tag), nil
|
||
}
|
||
|
||
func runWelcome(ctx *common.RuntimeContext) error {
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// --issues takes priority over --tag
|
||
if issueArg := ctx.Arg("issues"); issueArg != "" {
|
||
return runWelcomeByIssueNumbers(ctx, issueArg)
|
||
}
|
||
|
||
tagNames := ctx.Arg("tag")
|
||
if tagNames == "" {
|
||
tagNames = "good first issue,help wanted"
|
||
}
|
||
|
||
// resolve tag names → ids
|
||
tagMap, err := resolveTags(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var tagIDs []string
|
||
for _, name := range strings.Split(tagNames, ",") {
|
||
name = strings.TrimSpace(name)
|
||
if id, ok := tagMap[name]; ok {
|
||
tagIDs = append(tagIDs, strconv.Itoa(id))
|
||
}
|
||
}
|
||
if len(tagIDs) == 0 {
|
||
return fmt.Errorf("未找到匹配的标签: %s (可用: %v)", tagNames, tagNamesList(tagMap))
|
||
}
|
||
|
||
// fetch open issues with these tags
|
||
issues, err := fetchTaggedIssues(ctx, tagIDs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
return processIssues(ctx, issues)
|
||
}
|
||
|
||
// runWelcomeByIssueNumbers directly processes specified issue numbers,
|
||
// skipping tag resolution and tag-based issue fetching.
|
||
func runWelcomeByIssueNumbers(ctx *common.RuntimeContext, issueArg string) error {
|
||
issueNums, err := parseIssueNumbers(issueArg)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var issues []issueInfo
|
||
for _, num := range issueNums {
|
||
info, err := fetchIssueDetail(ctx, num)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 issue #%d 失败: %w", num, err)
|
||
}
|
||
issues = append(issues, info)
|
||
}
|
||
|
||
return processIssues(ctx, issues)
|
||
}
|
||
|
||
// parseIssueNumbers parses a comma-separated string of issue numbers.
|
||
func parseIssueNumbers(s string) ([]int, error) {
|
||
parts := strings.Split(s, ",")
|
||
var nums []int
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
continue
|
||
}
|
||
n, err := strconv.Atoi(p)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("无效的 issue 编号: %q (必须是数字)", p)
|
||
}
|
||
nums = append(nums, n)
|
||
}
|
||
if len(nums) == 0 {
|
||
return nil, fmt.Errorf("--issues 参数为空")
|
||
}
|
||
return nums, nil
|
||
}
|
||
|
||
// fetchIssueDetail fetches a single issue's subject and description by number.
|
||
func fetchIssueDetail(ctx *common.RuntimeContext, issueNumber int) (issueInfo, error) {
|
||
path := fmt.Sprintf("/v1/%s/%s/issues/%d", ctx.Owner, ctx.Repo, issueNumber)
|
||
env, err := ctx.CallAPIWithQuery("GET", path, nil)
|
||
if err != nil {
|
||
return issueInfo{}, err
|
||
}
|
||
data, _ := env.Data.(map[string]interface{})
|
||
subj := getString(data, "subject")
|
||
if subj == "" {
|
||
subj = fmt.Sprintf("issue #%d", issueNumber)
|
||
}
|
||
desc := getString(data, "description")
|
||
return issueInfo{number: issueNumber, subject: subj, description: desc}, nil
|
||
}
|
||
|
||
// processIssues handles the common issue processing loop used by both
|
||
// --issues and --tag paths.
|
||
func processIssues(ctx *common.RuntimeContext, issues []issueInfo) error {
|
||
tmpl := ctx.Arg("template")
|
||
if tmpl == "" {
|
||
tmpl = ""
|
||
}
|
||
|
||
type result struct {
|
||
num int
|
||
action string
|
||
msg string
|
||
}
|
||
var results []result
|
||
|
||
for _, issue := range issues {
|
||
force := ctx.Arg("force") == "true"
|
||
if !force && hasWelcomeComment(ctx, issue.number) {
|
||
results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)})
|
||
continue
|
||
}
|
||
|
||
// Render per-issue message with issue-specific variables.
|
||
body := renderComment(ctx, issue, tmpl)
|
||
|
||
if ctx.IsDryRun() {
|
||
fmt.Printf("\n--- 预览 #%d \"%s\" ---\n%s\n---\n", issue.number, issue.subject, body)
|
||
proceed, err := common.ConfirmAction(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !proceed {
|
||
results = append(results, result{issue.number, "skipped", "用户取消"})
|
||
continue
|
||
}
|
||
}
|
||
|
||
if err := addComment(ctx, issue.number, body); err != nil {
|
||
results = append(results, result{issue.number, "error", err.Error()})
|
||
} else {
|
||
results = append(results, result{issue.number, "added", fmt.Sprintf("#%d \"%s\" — 已添加引导评论", issue.number, issue.subject)})
|
||
}
|
||
}
|
||
|
||
// output
|
||
added := 0
|
||
skipped := 0
|
||
errors := 0
|
||
for _, r := range results {
|
||
switch r.action {
|
||
case "added":
|
||
added++
|
||
case "skipped":
|
||
skipped++
|
||
case "error":
|
||
errors++
|
||
}
|
||
}
|
||
fmt.Printf("\n完成: 添加 %d, 跳过 %d, 错误 %d\n", added, skipped, errors)
|
||
for _, r := range results {
|
||
fmt.Printf(" [%s] %s\n", r.action, r.msg)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func resolveTags(ctx *common.RuntimeContext) (map[string]int, error) {
|
||
key := ctx.Owner + "/" + ctx.Repo
|
||
if cached, ok := tagCache.Load(key); ok {
|
||
return cached.(map[string]int), nil
|
||
}
|
||
|
||
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
|
||
q := url.Values{}
|
||
q.Set("only_name", "true")
|
||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("获取标签列表失败: %w", err)
|
||
}
|
||
|
||
data, _ := env.Data.(map[string]interface{})
|
||
raw, _ := data["issue_tags"].([]interface{})
|
||
tags := make(map[string]int)
|
||
for _, item := range raw {
|
||
if t, ok := item.(map[string]interface{}); ok {
|
||
if name, ok := t["name"].(string); ok && name != "" {
|
||
switch v := t["id"].(type) {
|
||
case float64:
|
||
tags[name] = int(v)
|
||
case int:
|
||
tags[name] = v
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if len(tags) == 0 {
|
||
return nil, fmt.Errorf("项目没有配置任务标签,请先在 GitLink 网页端创建")
|
||
}
|
||
tagCache.Store(key, tags)
|
||
return tags, nil
|
||
}
|
||
|
||
func tagNamesList(tags map[string]int) []string {
|
||
var names []string
|
||
for n := range tags {
|
||
names = append(names, n)
|
||
}
|
||
return names
|
||
}
|
||
|
||
type issueInfo struct {
|
||
number int
|
||
subject string
|
||
description string
|
||
}
|
||
|
||
func fetchTaggedIssues(ctx *common.RuntimeContext, tagIDs []string) ([]issueInfo, error) {
|
||
var all []issueInfo
|
||
page := 1
|
||
|
||
for {
|
||
q := url.Values{}
|
||
q.Set("state", "open")
|
||
q.Set("page", strconv.Itoa(page))
|
||
q.Set("limit", "100")
|
||
q.Set("issue_tag_ids", strings.Join(tagIDs, ","))
|
||
|
||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
data, _ := env.Data.(map[string]interface{})
|
||
issues, _ := data["issues"].([]interface{})
|
||
if len(issues) == 0 {
|
||
break
|
||
}
|
||
|
||
for _, item := range issues {
|
||
if issue, ok := item.(map[string]interface{}); ok {
|
||
all = append(all, issueInfo{
|
||
number: getInt(issue, "project_issues_index"),
|
||
subject: getString(issue, "subject"),
|
||
})
|
||
}
|
||
}
|
||
|
||
total := getInt(data, "total_count")
|
||
if page*100 >= total {
|
||
break
|
||
}
|
||
page++
|
||
}
|
||
|
||
return all, nil
|
||
}
|
||
|
||
func hasWelcomeComment(ctx *common.RuntimeContext, issueNumber int) bool {
|
||
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
|
||
q := url.Values{}
|
||
q.Set("limit", "100")
|
||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
data, _ := env.Data.(map[string]interface{})
|
||
journals, _ := data["journals"].([]interface{})
|
||
for _, j := range journals {
|
||
if jm, ok := j.(map[string]interface{}); ok {
|
||
if notes := getString(jm, "notes"); strings.Contains(notes, marker) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func addComment(ctx *common.RuntimeContext, issueNumber int, body string) error {
|
||
path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber)
|
||
payload := map[string]interface{}{"notes": marker + "\n\n" + body}
|
||
_, err := ctx.CallAPI("POST", path, payload)
|
||
return err
|
||
}
|
||
|
||
// renderComment renders the comment body for a specific issue.
|
||
// It uses the --template if provided, otherwise generates an issue-aware default.
|
||
func renderComment(ctx *common.RuntimeContext, issue issueInfo, customTmpl string) string {
|
||
tmpl := customTmpl
|
||
if tmpl == "" {
|
||
tmpl = defaultTemplate(ctx.Owner, ctx.Repo, issue)
|
||
}
|
||
body := strings.NewReplacer(
|
||
"{login}", ctx.Owner,
|
||
"{number}", strconv.Itoa(issue.number),
|
||
"{subject}", issue.subject,
|
||
"{description}", issue.description,
|
||
).Replace(tmpl)
|
||
return body
|
||
}
|
||
|
||
// defaultTemplate returns an issue-aware onboarding message.
|
||
func defaultTemplate(owner, repo string, issue issueInfo) string {
|
||
summary := issue.subject
|
||
if len(issue.description) > 200 {
|
||
summary = issue.description[:200] + "..."
|
||
} else if issue.description != "" {
|
||
summary = issue.description
|
||
}
|
||
return fmt.Sprintf(`## 欢迎贡献!:wave:
|
||
|
||
感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。
|
||
|
||
### :bulb: 关于本 Issue:{subject}
|
||
|
||
%s
|
||
|
||
### :rocket: 参与步骤
|
||
1. **Fork 仓库** 并克隆到本地
|
||
2. 创建新分支:` + "`git checkout -b fix/issue-{number}`" + `
|
||
3. 参照上方 issue 描述修改代码
|
||
4. 推送到你的 Fork 后创建 Pull Request
|
||
|
||
### :memo: 注意事项
|
||
- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有)
|
||
- 如有疑问,欢迎在评论区留言讨论
|
||
|
||
期待你的 PR!`, owner, repo, owner, repo, summary, owner, repo)
|
||
}
|
||
|
||
func getString(m map[string]interface{}, key string) string {
|
||
if v, ok := m[key]; ok {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func getInt(m map[string]interface{}, key string) int {
|
||
switch v := m[key].(type) {
|
||
case float64:
|
||
return int(v)
|
||
case int:
|
||
return v
|
||
}
|
||
return 0
|
||
}
|