gitlink-cli/shortcuts/onboard/onboarding.go

394 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}