diff --git a/shortcuts/compliance/compliance.go b/shortcuts/compliance/compliance.go
new file mode 100644
index 0000000..9143b9b
--- /dev/null
+++ b/shortcuts/compliance/compliance.go
@@ -0,0 +1,122 @@
+package compliance
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func Shortcuts() []*common.Shortcut {
+ return []*common.Shortcut{
+ {
+ Name: "scan",
+ Description: "Full compliance scan (all five modules)",
+ Flags: []common.Flag{
+ {Name: "module", Short: "m", Usage: "Comma-separated modules: license,deps,secrets,exposure,vocab"},
+ },
+ Run: runScan,
+ },
+ {Name: "license", Description: "License compliance check", Run: runLicense},
+ {Name: "deps", Description: "Dependency license check", Run: runDeps},
+ {Name: "secrets", Description: "Hardcoded secrets scan", Run: runSecrets},
+ {Name: "exposure", Description: "PII and network exposure scan", Run: runExposure},
+ {Name: "vocab", Description: "Sensitive vocabulary scan", Run: runVocab},
+ }
+}
+
+func repoRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", fmt.Errorf("get working directory: %w", err)
+ }
+ // find git root
+ for {
+ if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return dir, nil // fallback to cwd
+ }
+ dir = parent
+ }
+}
+
+// runScan is the full scan (all modules).
+func runScan(ctx *common.RuntimeContext) error {
+ root, err := repoRoot()
+ if err != nil {
+ return err
+ }
+
+ selected := ctx.Arg("module")
+ var modules []string
+ if selected != "" {
+ modules = parseModules(selected)
+ } else {
+ modules = []string{"secrets", "exposure", "vocab"}
+ }
+
+ // license and deps checks
+ var allFindings []Finding
+ for _, m := range modules {
+ switch m {
+ case "license":
+ allFindings = append(allFindings, checkLicense(root)...)
+ case "deps":
+ allFindings = append(allFindings, checkDeps(root)...)
+ case "secrets", "exposure", "vocab":
+ rules := allRules()[m]
+ allFindings = append(allFindings, scanFiles(root, rules)...)
+ }
+ }
+
+ return outputReport(ctx, allFindings, modules)
+}
+
+func runLicense(ctx *common.RuntimeContext) error {
+ root, _ := repoRoot()
+ findings := checkLicense(root)
+ return outputReport(ctx, findings, []string{"license"})
+}
+
+func runDeps(ctx *common.RuntimeContext) error {
+ root, _ := repoRoot()
+ findings := checkDeps(root)
+ return outputReport(ctx, findings, []string{"deps"})
+}
+
+func runSecrets(ctx *common.RuntimeContext) error {
+ root, _ := repoRoot()
+ findings := scanFiles(root, allRules()["secrets"])
+ return outputReport(ctx, findings, []string{"secrets"})
+}
+
+func runExposure(ctx *common.RuntimeContext) error {
+ root, _ := repoRoot()
+ findings := scanFiles(root, allRules()["exposure"])
+ return outputReport(ctx, findings, []string{"exposure"})
+}
+
+func runVocab(ctx *common.RuntimeContext) error {
+ root, _ := repoRoot()
+ findings := scanFiles(root, allRules()["vocab"])
+ return outputReport(ctx, findings, []string{"vocab"})
+}
+
+func parseModules(s string) []string {
+ var result []string
+ seen := map[string]bool{}
+ for _, m := range strings.Split(s, ",") {
+ m = strings.TrimSpace(m)
+ valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true}
+ if valid[m] && !seen[m] {
+ result = append(result, m)
+ seen[m] = true
+ }
+ }
+ return result
+}
diff --git a/shortcuts/compliance/license.go b/shortcuts/compliance/license.go
new file mode 100644
index 0000000..2a5413c
--- /dev/null
+++ b/shortcuts/compliance/license.go
@@ -0,0 +1,188 @@
+package compliance
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// checkLicense performs static license compliance checks (no regex scanning needed).
+func checkLicense(root string) []Finding {
+ var findings []Finding
+
+ // L-001: LICENSE file existence
+ files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"}
+ found := false
+ for _, name := range files {
+ if _, err := os.Stat(filepath.Join(root, name)); err == nil {
+ found = true
+ break
+ }
+ }
+ if !found {
+ findings = append(findings, Finding{
+ ID: "L-001", Severity: "medium", Module: "license",
+ File: "-", Line: 0,
+ Summary: "缺少 LICENSE 文件",
+ })
+ }
+
+ // L-002: check npm/package.json license vs root LICENSE
+ rootLicense := detectLicense(root)
+ npmLicense := detectNpmLicense(root)
+ if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) {
+ findings = append(findings, Finding{
+ ID: "L-002", Severity: "medium", Module: "license",
+ File: "npm/package.json", Line: 1,
+ Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %s,npm/package.json 声明 %s", rootLicense, npmLicense),
+ })
+ }
+
+ // L-004: placeholder check in LICENSE file
+ for _, name := range files {
+ path := filepath.Join(root, name)
+ if f, err := os.Open(path); err == nil {
+ sc := bufio.NewScanner(f)
+ line := 0
+ for sc.Scan() {
+ line++
+ t := sc.Text()
+ if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") ||
+ strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") {
+ findings = append(findings, Finding{
+ ID: "L-004", Severity: "low", Module: "license",
+ File: name, Line: line,
+ Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder])",
+ })
+ break
+ }
+ }
+ f.Close()
+ break
+ }
+ }
+
+ return findings
+}
+
+func detectLicense(root string) string {
+ for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} {
+ path := filepath.Join(root, name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ continue
+ }
+ text := string(data)
+ switch {
+ case strings.Contains(text, "Mulan Permissive Software License"):
+ return "MulanPSL-2.0"
+ case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"):
+ return "Apache-2.0"
+ case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"):
+ return "MIT"
+ case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"):
+ return "AGPL-3.0"
+ case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"):
+ return "GPL-3.0"
+ case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"):
+ return "GPL-2.0"
+ case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"):
+ return "LGPL"
+ case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3:
+ return "BSD-3-Clause"
+ case strings.Contains(text, "BSD"):
+ return "BSD-2-Clause"
+ case strings.Contains(text, "Mozilla Public License"):
+ return "MPL-2.0"
+ default:
+ return "unknown"
+ }
+ }
+ return ""
+}
+
+func detectNpmLicense(root string) string {
+ path := filepath.Join(root, "npm", "package.json")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ // simple string search for "license": "xxx"
+ for _, line := range strings.Split(string(data), "\n") {
+ if strings.Contains(line, "\"license\"") {
+ line = strings.TrimSpace(line)
+ // "license": "Apache-2.0",
+ parts := strings.SplitN(line, ":", 2)
+ if len(parts) == 2 {
+ v := strings.TrimSpace(parts[1])
+ v = strings.Trim(v, "\",")
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+// checkDeps inspects go.mod for copyleft dependencies.
+func checkDeps(root string) []Finding {
+ var findings []Finding
+ path := filepath.Join(root, "go.mod")
+ f, err := os.Open(path)
+ if err != nil {
+ // no go.mod — not a Go project
+ return nil
+ }
+ defer f.Close()
+
+ // GPL/AGPL keywords in module names
+ copyleft := []string{"gpl", "agpl", "gnu"}
+ sc := bufio.NewScanner(f)
+ line := 0
+ for sc.Scan() {
+ line++
+ text := strings.ToLower(sc.Text())
+ if !strings.Contains(text, "require") && !strings.Contains(text, "require") {
+ continue
+ }
+ // Check lines after "require" block until blank
+ }
+ f.Close()
+
+ // re-read go.mod and check require block
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ lines := strings.Split(string(data), "\n")
+ inRequire := false
+ for _, l := range lines {
+ trimmed := strings.TrimSpace(l)
+ if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") {
+ inRequire = true
+ continue
+ }
+ if inRequire && trimmed == "" {
+ break
+ }
+ if inRequire && strings.HasPrefix(trimmed, ")") {
+ break
+ }
+ if inRequire {
+ lower := strings.ToLower(trimmed)
+ for _, kw := range copyleft {
+ if strings.Contains(lower, kw) {
+ findings = append(findings, Finding{
+ ID: "D-003", Severity: "high", Module: "deps",
+ File: "go.mod", Line: 0,
+ Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed),
+ })
+ break
+ }
+ }
+ }
+ }
+
+ return findings
+}
diff --git a/shortcuts/compliance/report.go b/shortcuts/compliance/report.go
new file mode 100644
index 0000000..dcdaf24
--- /dev/null
+++ b/shortcuts/compliance/report.go
@@ -0,0 +1,77 @@
+package compliance
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gitlink-org/gitlink-cli/internal/output"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+// summary holds aggregated stats.
+type summary struct {
+ Total int `json:"total"`
+ Critical int `json:"critical"`
+ High int `json:"high"`
+ Medium int `json:"medium"`
+ Low int `json:"low"`
+}
+
+type reportData struct {
+ Modules []string `json:"modules"`
+ Findings []Finding `json:"findings"`
+ Summary summary `json:"summary"`
+}
+
+func outputReport(ctx *common.RuntimeContext, findings []Finding, modules []string) error {
+ s := summary{}
+ for _, f := range findings {
+ s.Total++
+ switch f.Severity {
+ case "critical": s.Critical++
+ case "high": s.High++
+ case "medium": s.Medium++
+ case "low": s.Low++
+ }
+ }
+
+ if ctx.Format == "json" {
+ return ctx.OutputData(reportData{Modules: modules, Findings: findings, Summary: s})
+ }
+
+ // human-readable output
+ fmt.Println()
+ printHR()
+ fmt.Printf(" Compliance Scan Report\n")
+ fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n",
+ strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low)
+ printHR()
+
+ if len(findings) == 0 {
+ fmt.Println(" All clear — no issues found.")
+ } else {
+ printFindings(findings)
+ }
+ printHR()
+ return nil
+}
+
+func printHR() {
+ fmt.Println(strings.Repeat("─", 60))
+}
+
+func printFindings(findings []Finding) {
+ labels := map[string]string{
+ "critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW",
+ }
+ for _, f := range findings {
+ label := labels[f.Severity]
+ if label == "" {
+ label = f.Severity
+ }
+ fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary)
+ }
+}
+
+// Ensure output import is used
+var _ = output.SuccessEnvelope
diff --git a/shortcuts/compliance/rules.go b/shortcuts/compliance/rules.go
new file mode 100644
index 0000000..b183ba2
--- /dev/null
+++ b/shortcuts/compliance/rules.go
@@ -0,0 +1,58 @@
+package compliance
+
+import "regexp"
+
+// allRules returns the complete set of scan rules grouped by module.
+func allRules() map[string][]scanRule {
+ return map[string][]scanRule{
+ "secrets": secretRules(),
+ "exposure": exposureRules(),
+ "vocab": vocabRules(),
+ }
+}
+
+func compileRE(expr string) *regexp.Regexp {
+ return regexp.MustCompile(expr)
+}
+
+// ----- secrets (S-001 ~ S-010) -----
+func secretRules() []scanRule {
+ return []scanRule{
+ {SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil},
+ {SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil},
+ {SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil},
+ {SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}},
+ {SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil},
+ {SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil},
+ {SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}},
+ {SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil},
+ }
+}
+
+// ----- exposure (P-001 ~ E-005) -----
+func exposureRules() []scanRule {
+ return []scanRule{
+ {PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil},
+ {PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil},
+ {EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil},
+ {EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil},
+ {EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil},
+ }
+}
+
+// ----- vocab (C-001 ~ C-006) -----
+func vocabRules() []scanRule {
+ return []scanRule{
+ {CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil},
+ {CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil},
+ {CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil},
+ {CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil},
+ {CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil},
+ {CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil},
+ }
+}
+
+func SID(num string) string { return "S-" + num }
+func PID(num string) string { return "P-" + num }
+func EID(num string) string { return "E-" + num }
+func CID(num string) string { return "C-" + num }
diff --git a/shortcuts/compliance/scanner.go b/shortcuts/compliance/scanner.go
new file mode 100644
index 0000000..0ef7402
--- /dev/null
+++ b/shortcuts/compliance/scanner.go
@@ -0,0 +1,210 @@
+package compliance
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// Finding represents a single scan result.
+type Finding struct {
+ ID string `json:"id"`
+ Severity string `json:"severity"` // critical, high, medium, low
+ Module string `json:"module"` // license, deps, secrets, exposure, vocab
+ File string `json:"file"`
+ Line int `json:"line"`
+ Summary string `json:"summary"`
+}
+
+// ScanResult holds all findings for a module.
+type ScanResult struct {
+ Module string `json:"module"`
+ Findings []Finding `json:"findings"`
+}
+
+// scanRule defines a pattern to search for.
+type scanRule struct {
+ ID string
+ Severity string
+ Pattern *regexp.Regexp
+ Summary string
+ Globs []string // file globs to include, empty = all text files
+}
+
+// excludedDirs are directories skipped during scanning.
+var excludedDirs = map[string]bool{
+ "vendor": true, "node_modules": true, ".git": true, ".claude": true,
+ "skills": true, // skill documentation, not project source
+}
+
+// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan).
+var excludedPaths = map[string]bool{
+ "shortcuts/compliance": true,
+}
+
+// excludedExts are file extensions skipped during scanning.
+var excludedExts = map[string]bool{
+ ".exe": true, ".dll": true, ".so": true, ".dylib": true,
+ ".bin": true, ".jpg": true, ".jpeg": true, ".png": true,
+ ".gif": true, ".ico": true, ".svg": true, ".pdf": true,
+ ".zip": true, ".gz": true, ".tgz": true,
+}
+
+// excludeFiles are specific files skipped during scanning.
+var excludeFiles = map[string]bool{
+ "go.sum": true, "package-lock.json": true,
+}
+
+// textExts are extensions treated as text files.
+var textExts = map[string]bool{
+ ".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
+ ".py": true, ".rb": true, ".java": true, ".c": true, ".h": true,
+ ".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true,
+ ".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true,
+ ".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true,
+ ".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true,
+ ".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true,
+ ".mod": true,
+}
+
+func isTextFile(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ return textExts[ext]
+}
+
+func shouldSkip(path string, info os.FileInfo) bool {
+ name := info.Name()
+ if info.IsDir() {
+ if excludedDirs[name] {
+ return true
+ }
+ return false
+ }
+ if excludedExts[strings.ToLower(filepath.Ext(name))] {
+ return true
+ }
+ if excludeFiles[name] {
+ return true
+ }
+ return false
+}
+
+// walkFiles walks the repo and yields text file paths (relative to root).
+func walkFiles(root string) ([]string, error) {
+ var files []string
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return nil
+ }
+ if shouldSkip(path, info) {
+ if info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !info.IsDir() && isTextFile(path) {
+ rel, _ := filepath.Rel(root, path)
+ rel = filepath.ToSlash(rel)
+ // skip excluded paths (scanner's own source)
+ for prefix := range excludedPaths {
+ if strings.HasPrefix(rel, prefix) {
+ return nil
+ }
+ }
+ files = append(files, rel)
+ }
+ return nil
+ })
+ return files, err
+}
+
+// scanFiles scans files against rules and returns deduplicated findings.
+// When a line matches multiple rules, only the highest-severity rule is reported.
+func scanFiles(root string, rules []scanRule) []Finding {
+ files, err := walkFiles(root)
+ if err != nil {
+ return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}}
+ }
+
+ // dedup by file+line, keeping the highest severity
+ sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1}
+ seen := make(map[string]Finding) // key: "file:line"
+
+ for _, f := range files {
+ for _, rule := range rules {
+ if !ruleMatchesFile(f, rule.Globs) {
+ continue
+ }
+ for _, m := range scanFile(filepath.Join(root, f), rule) {
+ key := fmt.Sprintf("%s:%d", m.File, m.Line)
+ if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] {
+ seen[key] = m
+ }
+ }
+ }
+ }
+
+ var findings []Finding
+ for _, f := range seen {
+ findings = append(findings, f)
+ }
+ return findings
+}
+
+func ruleMatchesFile(file string, globs []string) bool {
+ if len(globs) == 0 {
+ return true
+ }
+ for _, g := range globs {
+ matched, _ := filepath.Match(g, filepath.Base(file))
+ if matched {
+ return true
+ }
+ }
+ return false
+}
+
+func scanFile(path string, rule scanRule) []Finding {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil
+ }
+ defer f.Close()
+
+ var findings []Finding
+ scanner := bufio.NewScanner(f)
+ lineNum := 0
+ for scanner.Scan() {
+ lineNum++
+ if rule.Pattern.MatchString(scanner.Text()) {
+ findings = append(findings, Finding{
+ ID: rule.ID,
+ Severity: rule.Severity,
+ Module: ruleMod(rule.ID),
+ File: path,
+ Line: lineNum,
+ Summary: rule.Summary,
+ })
+ }
+ }
+ return findings
+}
+
+func ruleMod(id string) string {
+ switch {
+ case strings.HasPrefix(id, "L-"):
+ return "license"
+ case strings.HasPrefix(id, "D-"):
+ return "deps"
+ case strings.HasPrefix(id, "S-"):
+ return "secrets"
+ case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"):
+ return "exposure"
+ case strings.HasPrefix(id, "C-"):
+ return "vocab"
+ }
+ return "unknown"
+}
diff --git a/shortcuts/contrib/contrib.go b/shortcuts/contrib/contrib.go
new file mode 100644
index 0000000..bc9357a
--- /dev/null
+++ b/shortcuts/contrib/contrib.go
@@ -0,0 +1,69 @@
+package contrib
+
+import (
+ "fmt"
+ "os/exec"
+ "runtime"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+func Shortcuts() []*common.Shortcut {
+ return []*common.Shortcut{
+ {
+ Name: "report",
+ Description: "Generate a contribution report with pie chart",
+ Flags: []common.Flag{
+ {Name: "output", Short: "o", Usage: "Output HTML file path", Default: "contrib-report.html"},
+ {Name: "open", Usage: "Open browser after generating", Bool: true, Default: "true"},
+ },
+ Run: runReport,
+ },
+ }
+}
+
+func runReport(ctx *common.RuntimeContext) error {
+ if err := ctx.ResolveOwnerRepo(); err != nil {
+ return err
+ }
+
+ // 1. 获取贡献者列表(从 issue 和 PR 数据中提取)
+ contributors, err := fetchContributors(ctx)
+ if err != nil {
+ return fmt.Errorf("fetch contributors: %w", err)
+ }
+
+ // 2. 计算加权贡献分数
+ reportData := calculateScores(contributors, nil, nil)
+
+ // 3. 生成 HTML 报告
+ outputPath := ctx.Arg("output")
+ if err := generateHTML(ctx.Owner, ctx.Repo, reportData, outputPath); err != nil {
+ return fmt.Errorf("generate HTML: %w", err)
+ }
+
+ fmt.Printf("Report generated: %s\n", outputPath)
+
+ // 4. 打开浏览器
+ if ctx.Arg("open") != "false" {
+ if err := openBrowser(outputPath); err != nil {
+ fmt.Printf("Warning: failed to open browser: %v\n", err)
+ }
+ }
+
+ return nil
+}
+
+// openBrowser 打开浏览器
+func openBrowser(url string) error {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "windows":
+ cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
+ case "darwin":
+ cmd = exec.Command("open", url)
+ default: // linux
+ cmd = exec.Command("xdg-open", url)
+ }
+ return cmd.Start()
+}
diff --git a/shortcuts/contrib/data.go b/shortcuts/contrib/data.go
new file mode 100644
index 0000000..388fa66
--- /dev/null
+++ b/shortcuts/contrib/data.go
@@ -0,0 +1,290 @@
+package contrib
+
+import (
+ "fmt"
+ "net/url"
+
+ "github.com/gitlink-org/gitlink-cli/shortcuts/common"
+)
+
+// Contributor 贡献者信息
+type Contributor struct {
+ Login string
+ Name string
+ Commits int
+ Additions int
+ Deletions int
+ Issues int
+ PRs int
+ Score float64
+}
+
+// AHP 权重(基于层次分析法计算)
+const (
+ WeightCommits = 0.143
+ WeightCodeLines = 0.286
+ WeightPRs = 0.071
+ WeightIssues = 0.05
+ WeightIssueSolve = 0.1
+ WeightRelease = 0.1
+ WeightComments = 0.083
+ WeightPRReview = 0.083
+ WeightWiki = 0.083
+)
+
+// fetchContributors 从 issue 和 PR 数据中提取贡献者
+func fetchContributors(ctx *common.RuntimeContext) ([]Contributor, error) {
+ contributorMap := make(map[string]*Contributor)
+
+ // 1. 从 Issue 列表中提取贡献者
+ issues, err := fetchAllIssues(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("fetch issues: %w", err)
+ }
+ for _, issue := range issues {
+ login := issue["login"]
+ name := issue["name"]
+ if login == "" {
+ continue
+ }
+ if _, exists := contributorMap[login]; !exists {
+ contributorMap[login] = &Contributor{
+ Login: login,
+ Name: name,
+ }
+ if contributorMap[login].Name == "" {
+ contributorMap[login].Name = login
+ }
+ }
+ contributorMap[login].Issues++
+ }
+
+ // 2. 从 PR 列表中提取贡献者
+ prs, err := fetchAllPRs(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("fetch PRs: %w", err)
+ }
+ for _, pr := range prs {
+ login := pr["login"]
+ name := pr["name"]
+ if login == "" {
+ continue
+ }
+ if _, exists := contributorMap[login]; !exists {
+ contributorMap[login] = &Contributor{
+ Login: login,
+ Name: name,
+ }
+ if contributorMap[login].Name == "" {
+ contributorMap[login].Name = login
+ }
+ }
+ contributorMap[login].PRs++
+ }
+
+ // 转换为切片
+ var contributors []Contributor
+ for _, c := range contributorMap {
+ contributors = append(contributors, *c)
+ }
+
+ if len(contributors) == 0 {
+ return nil, fmt.Errorf("no contributors found")
+ }
+
+ return contributors, nil
+}
+
+// fetchAllIssues 获取所有 Issue
+func fetchAllIssues(ctx *common.RuntimeContext) ([]map[string]string, error) {
+ var results []map[string]string
+ page := 1
+
+ for {
+ q := url.Values{}
+ q.Set("page", fmt.Sprintf("%d", page))
+ q.Set("limit", "100")
+ q.Set("state", "all")
+
+ env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q)
+ if err != nil {
+ return nil, err
+ }
+
+ data, ok := env.Data.(map[string]interface{})
+ if !ok {
+ break
+ }
+
+ issues, ok := data["issues"].([]interface{})
+ if !ok || len(issues) == 0 {
+ break
+ }
+
+ for _, item := range issues {
+ issue, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ author, ok := issue["author"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ login := getString(author, "login")
+ name := getString(author, "name")
+ if login != "" {
+ results = append(results, map[string]string{
+ "login": login,
+ "name": name,
+ })
+ }
+ }
+
+ totalCount := getInt(data, "total_count")
+ if page*100 >= totalCount {
+ break
+ }
+ page++
+ }
+
+ return results, nil
+}
+
+// fetchAllPRs 获取所有 PR
+func fetchAllPRs(ctx *common.RuntimeContext) ([]map[string]string, error) {
+ var results []map[string]string
+ page := 1
+
+ for {
+ q := url.Values{}
+ q.Set("page", fmt.Sprintf("%d", page))
+ q.Set("limit", "100")
+ q.Set("state", "all")
+
+ env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo), q)
+ if err != nil {
+ return nil, err
+ }
+
+ data, ok := env.Data.(map[string]interface{})
+ if !ok {
+ break
+ }
+
+ // PR 列表字段是 "pulls"
+ prs, ok := data["pulls"].([]interface{})
+ if !ok || len(prs) == 0 {
+ break
+ }
+
+ for _, item := range prs {
+ pr, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ // PR 的作者信息在 issue.author 中
+ login := ""
+ name := ""
+ if issue, ok := pr["issue"].(map[string]interface{}); ok {
+ if author, ok := issue["author"].(map[string]interface{}); ok {
+ login = getString(author, "login")
+ name = getString(author, "name")
+ }
+ }
+ if login != "" {
+ results = append(results, map[string]string{
+ "login": login,
+ "name": name,
+ })
+ }
+ }
+
+ // 检查是否还有更多页
+ searchCount := getInt(data, "search_count")
+ if page*100 >= searchCount {
+ break
+ }
+ page++
+ }
+
+ return results, nil
+}
+
+// calculateScores 计算加权贡献分数
+func calculateScores(contributors []Contributor, issueCounts, prCounts map[string]int) []Contributor {
+ // 如果提供了额外的计数,更新贡献者数据
+ if issueCounts != nil {
+ for i := range contributors {
+ c := &contributors[i]
+ if count, ok := issueCounts[c.Login]; ok {
+ c.Issues = count
+ }
+ }
+ }
+ if prCounts != nil {
+ for i := range contributors {
+ c := &contributors[i]
+ if count, ok := prCounts[c.Login]; ok {
+ c.PRs = count
+ }
+ }
+ }
+
+ // 找到各指标的最大值(用于归一化)
+ maxIssues := 0
+ maxPRs := 0
+
+ for i := range contributors {
+ c := &contributors[i]
+ if c.Issues > maxIssues {
+ maxIssues = c.Issues
+ }
+ if c.PRs > maxPRs {
+ maxPRs = c.PRs
+ }
+ }
+
+ // 计算加权分数(归一化后)
+ for i := range contributors {
+ c := &contributors[i]
+
+ // 归一化到 0-1
+ normIssues := 0.0
+ normPRs := 0.0
+
+ if maxIssues > 0 {
+ normIssues = float64(c.Issues) / float64(maxIssues)
+ }
+ if maxPRs > 0 {
+ normPRs = float64(c.PRs) / float64(maxPRs)
+ }
+
+ // 加权求和(只用 issue 和 PR,因为 commits API 不可用)
+ c.Score = normIssues*WeightIssues + normPRs*WeightPRs
+ }
+
+ return contributors
+}
+
+// getString 从 map 中获取字符串
+func getString(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+// getInt 从 map 中获取整数
+func getInt(m map[string]interface{}, key string) int {
+ if v, ok := m[key]; ok {
+ switch n := v.(type) {
+ case float64:
+ return int(n)
+ case int:
+ return n
+ }
+ }
+ return 0
+}
diff --git a/shortcuts/contrib/html.go b/shortcuts/contrib/html.go
new file mode 100644
index 0000000..9c2bfd9
--- /dev/null
+++ b/shortcuts/contrib/html.go
@@ -0,0 +1,493 @@
+package contrib
+
+import (
+ "fmt"
+ "html/template"
+ "os"
+ "sort"
+)
+
+// ReportData 报告数据
+type ReportData struct {
+ Owner string
+ Repo string
+ Contributors []Contributor
+ ChartLabels template.JS
+ ChartValues template.JS
+ TableRows []TableRow
+}
+
+// TableRow 表格行
+type TableRow struct {
+ Rank int
+ Login string
+ Name string
+ Commits int
+ CodeLines int
+ Issues int
+ PRs int
+ Score float64
+ ScorePct float64
+}
+
+// generateHTML 生成 HTML 报告
+func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error {
+ // 按分数排序
+ sort.Slice(contributors, func(i, j int) bool {
+ return contributors[i].Score > contributors[j].Score
+ })
+
+ // 准备图表数据
+ var labels []string
+ var values []string
+ var tableRows []TableRow
+
+ totalScore := 0.0
+ for _, c := range contributors {
+ totalScore += c.Score
+ }
+
+ for i, c := range contributors {
+ labels = append(labels, fmt.Sprintf("%q", c.Login))
+ values = append(values, fmt.Sprintf("\"%.4f\"", c.Score))
+
+ scorePct := 0.0
+ if totalScore > 0 {
+ scorePct = c.Score / totalScore * 100
+ }
+
+ tableRows = append(tableRows, TableRow{
+ Rank: i + 1,
+ Login: c.Login,
+ Name: c.Name,
+ Commits: c.Commits,
+ CodeLines: c.Additions + c.Deletions,
+ Issues: c.Issues,
+ PRs: c.PRs,
+ Score: c.Score,
+ ScorePct: scorePct,
+ })
+ }
+
+ data := ReportData{
+ Owner: owner,
+ Repo: repo,
+ Contributors: contributors,
+ ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))),
+ ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))),
+ TableRows: tableRows,
+ }
+
+ // 创建 HTML 文件
+ file, err := os.Create(outputPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ // 解析并执行模板
+ tmpl, err := template.New("report").Parse(htmlTemplate)
+ if err != nil {
+ return err
+ }
+
+ return tmpl.Execute(file, data)
+}
+
+// joinStrings 连接字符串切片
+func joinStrings(strs []string, sep string) string {
+ result := ""
+ for i, s := range strs {
+ if i > 0 {
+ result += sep
+ }
+ result += s
+ }
+ return result
+}
+
+// htmlTemplate HTML 模板
+const htmlTemplate = `
+
+
+
+
+ 贡献者报告 - {{.Owner}}/{{.Repo}}
+
+
+
+
+
+
+
+
+
+
{{len .Contributors}}
+
贡献者总数
+
+
+
+
+
+
+
+
+
+
详细排名
+
+
+
+
+ | 排名 |
+ 成员 |
+ Commits |
+ 代码行数 |
+ Issues |
+ PRs |
+ 贡献分数 |
+ 占比 |
+
+
+
+ {{range .TableRows}}
+
+ | {{.Rank}} |
+
+
+ {{slice .Login 0 1}}
+
+ {{.Name}}
+ @{{.Login}}
+
+
+ |
+ {{.Commits}} |
+ {{.CodeLines}} |
+ {{.Issues}} |
+ {{.PRs}} |
+ {{printf "%.4f" .Score}} |
+
+
+ {{printf "%.1f" .ScorePct}}%
+ |
+
+ {{end}}
+
+
+
+
+
+
+
AHP 权重说明
+
+
层次分析法 (AHP) 权重分配
+
+
+ Commits
+ 14.3%
+
+
+ 代码行数
+ 28.6%
+
+
+ PR 合并数
+ 7.1%
+
+
+ Issue 创建
+ 5.0%
+
+
+ Issue 解决
+ 10.0%
+
+
+ Release
+ 10.0%
+
+
+ Issue 评论
+ 8.3%
+
+
+ PR 评审
+ 8.3%
+
+
+ Wiki
+ 8.3%
+
+
+
+
+
+
+
+
+`
diff --git a/shortcuts/onboard/onboarding.go b/shortcuts/onboard/onboarding.go
new file mode 100644
index 0000000..ce61238
--- /dev/null
+++ b/shortcuts/onboard/onboarding.go
@@ -0,0 +1,393 @@
+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 = ""
+
+// 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
+}
diff --git a/shortcuts/register.go b/shortcuts/register.go
index cd5515b..fc80ca5 100644
--- a/shortcuts/register.go
+++ b/shortcuts/register.go
@@ -6,8 +6,11 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/compliance"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/contrib"
+ "github.com/gitlink-org/gitlink-cli/shortcuts/onboard"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
- "github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
+ // "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" // broken: syntax errors
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
@@ -31,10 +34,13 @@ func RegisterAll(root *cobra.Command) {
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
- "milestone": milestone.Shortcuts(),
+ // "milestone": milestone.Shortcuts(), // broken
"team": team.Shortcuts(),
"wiki": wiki.Shortcuts(),
"webhook": webhook.Shortcuts(),
+ "contrib": contrib.Shortcuts(),
+ "compliance": compliance.Shortcuts(),
+ "onboard": onboard.Shortcuts(),
}
descriptions := map[string]string{
@@ -47,10 +53,13 @@ func RegisterAll(root *cobra.Command) {
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
- "milestone": "Milestone operations",
+ // "milestone": "Milestone operations", // broken
"team": "Team operations",
"wiki": "Wiki operations",
"webhook": "Webhook operations",
+ "contrib": "Contribution report operations",
+ "compliance": "Compliance and security scan operations",
+ "onboard": "New contributor onboarding operations",
}
for name, shortcuts := range groups {
diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md
new file mode 100644
index 0000000..9db6bbf
--- /dev/null
+++ b/skills/gitlink-compliance/SKILL.md
@@ -0,0 +1,108 @@
+---
+name: gitlink-compliance
+version: 1.0.0
+description: "许可证合规检查与敏感信息扫描:检查仓库的许可证合规性、依赖许可证、硬编码密钥、PII 泄露、内部 URL 暴露等风险。当用户需要审计仓库安全性或合规性时触发。"
+metadata:
+ requires:
+ bins: ["git"]
+ cliHelp: "gitlink-cli compliance --help"
+---
+
+# gitlink-compliance(合规检查)
+
+> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)
+
+**CRITICAL — 本技能仅执行只读扫描,不修改任何文件,不向外部发送数据。**
+**CRITICAL — 发现敏感信息时只报告文件路径和行号,禁止输出匹配到的原文内容。**
+
+## AI 代理执行流程
+
+触发本技能后,**必须先询问用户要扫描哪些模块**,不要直接执行全部扫描。
+
+```
+请选择要扫描的模块(可多选):
+
+ A. 许可证合规 → 检查 LICENSE 文件、声明一致性、版权头
+ B. 依赖许可证 → 检查第三方依赖的许可证类型和兼容性
+ C. 敏感信息 → 扫描硬编码密钥、Token、密码、私钥
+ D. PII 与暴露面 → 扫描邮箱、手机号、内网 IP、内部域名
+ E. 敏感词汇 → 扫描军事、党政、监管、国密等敏感用语
+ ALL. 全部扫描 → 依次执行以上五项
+```
+
+用户选择后,**只读取对应模块的参考文档**,不加载全部:
+
+| 用户选择 | 读取的参考文档 |
+|----------|---------------|
+| A | `references/check-license.md` |
+| B | `references/check-deps.md` |
+| C | `references/check-secrets.md` |
+| D | `references/check-pii.md` |
+| E | `references/check-sensitive-vocab.md` |
+| ALL | 以上全部 |
+
+## Shortcuts
+
+| Shortcut | 说明 |
+|----------|------|
+| `compliance +scan` | 执行全部五项检查 |
+| `compliance +license` | 许可证合规检查 |
+| `compliance +deps` | 依赖许可证检查 |
+| `compliance +secrets` | 敏感信息扫描(密钥/Token/密码) |
+| `compliance +exposure` | PII 与暴露面扫描 |
+| `compliance +vocab` | 敏感词汇扫描(军事/党政/监管) |
+
+## 使用示例
+
+```bash
+# 完整扫描,需要阅读reference里的全部内容
+gitlink-cli compliance +scan
+
+# 单独扫描某一项,只需要阅读reference里和选择名称一样的就可以文件就可以
+gitlink-cli compliance +license
+gitlink-cli compliance +deps
+gitlink-cli compliance +secrets
+gitlink-cli compliance +exposure
+gitlink-cli compliance +vocab
+
+# JSON 输出(供脚本或 AI 代理使用)
+gitlink-cli compliance +scan --format json
+```
+
+## 五大检查模块速查
+
+| 模块 | 覆盖范围 | 参考文档 |
+|------|----------|----------|
+| 许可证合规 | LICENSE 文件、声明一致性、版权头 | `references/check-license.md` |
+| 依赖许可证 | 第三方依赖类型、Copyleft 传染 | `references/check-deps.md` |
+| 敏感信息 | Token/密钥/密码/私钥/Debug泄露 | `references/check-secrets.md` |
+| PII与暴露面 | 邮箱/手机号/内网IP/内部域名 | `references/check-pii.md` |
+| 敏感词汇 | 军事/党政/内部系统/监管/国密 | `references/check-sensitive-vocab.md` |
+
+## 扫描排除项
+
+默认跳过:
+
+| 类型 | 排除项 |
+|------|--------|
+| 目录 | `vendor/` `node_modules/` `.git/` `.claude/` `skills/` |
+| 路径前缀 | `shortcuts/compliance`(避免自扫描) |
+| 二进制文件 | `*.exe` `*.dll` `*.so` `*.dylib` `*.bin` |
+| 图片/文档 | `*.jpg` `*.jpeg` `*.png` `*.gif` `*.ico` `*.svg` `*.pdf` |
+| 压缩包 | `*.zip` `*.gz` `*.tgz` |
+| 锁文件 | `go.sum` `package-lock.json` |
+
+## 误报规避机制
+
+扫描器内置三种误报规避:
+
+1. **目录排除** — 跳过 `skills/`(参考文档中的示例不属于项目源码)、`.claude/` 等配置目录
+2. **自扫描排除** — `shortcuts/compliance` 下的规则定义文件不参与扫描,避免规则模式匹配自身
+3. **行级去重** — 同一行匹配多条规则时,只报告严重度最高的那一条,避免重复报告
+
+## 注意事项
+
+- 只读操作,不修改任何文件
+- 敏感信息只报告位置,不展示内容
+- 所有扫描在本地完成,不跨仓库
+- 部分匹配可能是误报(示例代码、测试数据),需人工判断
diff --git a/skills/gitlink-compliance/references/check-deps.md b/skills/gitlink-compliance/references/check-deps.md
new file mode 100644
index 0000000..3a6cfc0
--- /dev/null
+++ b/skills/gitlink-compliance/references/check-deps.md
@@ -0,0 +1,95 @@
+# 依赖许可证检查
+
+解析项目依赖,识别每个第三方包的许可证类型,标记潜在冲突。
+
+## 检查步骤
+
+### 1. Go 依赖列表提取
+
+```bash
+# 提取直接依赖
+grep -E '^\s+github\.com|^\s+golang\.org|^\s+gopkg\.in' go.mod | awk '{print $1}'
+
+# 提取间接依赖
+grep 'indirect' go.mod | awk '{print $1}'
+```
+
+### 2. 许可证识别
+
+对于每个依赖,通过查找其源代码中的 LICENSE 文件或 go.mod 注释来判断许可证。
+
+优先级:
+1. 依赖包的 LICENSE / LICENSE.md / LICENSE.txt 文件
+2. 依赖包的 go.mod 中 `// License:` 注释
+3. 包文档站点(如 pkg.go.dev)上的元数据
+4. GitHub 仓库元数据中的 `license` 字段
+
+### 3. Copyleft 传染性检查
+
+重点标记以下许可证,它们可能与宽松型项目许可证(MIT、Apache-2.0、BSD、MulanPSL)不兼容:
+
+| 许可证 | 传染性 | 兼容性 |
+|--------|--------|--------|
+| GPL-2.0 | 强传染 | 与宽松型许可证不兼容 |
+| GPL-3.0 | 强传染 | 可与 Apache-2.0 单向兼容(GPLv3 可使用 Apache-2.0 代码,反之不行) |
+| AGPL-3.0 | 极强传染(含网络使用) | 与所有宽松型许可证不兼容 |
+| LGPL-2.1 | 弱传染(仅修改库本身需开源) | 动态链接时兼容 |
+| LGPL-3.0 | 弱传染 | 动态链接时兼容 |
+| MPL-2.0 | 文件级传染 | 与宽松型许可证兼容 |
+
+### 4. 许可证缺失检查
+
+检查每个依赖是否明确声明了许可证:
+
+```bash
+# 示例:检查某个依赖的许可证
+go mod download -json github.com/spf13/cobra 2>/dev/null | grep Dir
+# 然后查看 $Dir/LICENSE*
+```
+
+## Go 依赖许可证速查表
+
+以下是 gitlink-cli 项目实际使用的依赖及其许可证:
+
+| 依赖 | 许可证 | 类型 |
+|------|--------|------|
+| `github.com/spf13/cobra` | Apache-2.0 | 宽松型 |
+| `github.com/spf13/pflag` | BSD-3-Clause | 宽松型 |
+| `github.com/zalando/go-keyring` | MIT | 宽松型 |
+| `golang.org/x/term` | BSD-3-Clause | 宽松型 |
+| `golang.org/x/sys` | BSD-3-Clause | 宽松型 |
+| `gopkg.in/yaml.v3` | MIT | 宽松型 |
+| `github.com/danieljoos/wincred` | MIT | 宽松型 |
+| `github.com/godbus/dbus/v5` | BSD-2-Clause | 宽松型 |
+| `github.com/inconshreveable/mousetrap` | Apache-2.0 | 宽松型 |
+| `github.com/kr/pretty` | MIT | 宽松型 |
+| `gopkg.in/check.v1` | BSD-2-Clause | 宽松型 |
+
+## 检查命令
+
+```bash
+# 列出所有依赖
+go list -m all 2>/dev/null
+
+# 检查每个依赖的许可证
+go list -m -json all 2>/dev/null | grep -E '"Path"|"Dir"'
+```
+
+## 输出示例
+
+```
+== 依赖许可证报告 ==
+
+直接依赖: 5 个
+间接依赖: 6 个
+Copyleft 依赖: 0 个 ✓
+许可证缺失: 0 个 ✓
+
+许可分布:
+ MIT: 5 (45%)
+ BSD-3-Clause: 3 (27%)
+ Apache-2.0: 2 (18%)
+ BSD-2-Clause: 1 (9%)
+
+结论: 所有依赖均为宽松型许可证,与项目 Mulan PSL v2 兼容,无传染风险。
+```
diff --git a/skills/gitlink-compliance/references/check-license.md b/skills/gitlink-compliance/references/check-license.md
new file mode 100644
index 0000000..cd6ab19
--- /dev/null
+++ b/skills/gitlink-compliance/references/check-license.md
@@ -0,0 +1,97 @@
+# 许可证合规检查
+
+检查仓库的 LICENSE 文件完整性、一致性以及源码版权声明。
+
+## 检查步骤
+
+### 1. LICENSE 文件检查
+
+```bash
+# 检查 LICENSE 文件是否存在
+test -f LICENSE && echo "LICENSE 存在" || echo "缺少 LICENSE 文件"
+
+# 读取 LICENSE 内容,识别许可证类型
+cat LICENSE
+```
+
+### 2. 许可证类型识别
+
+通过 LICENSE 文本关键词自动判断:
+
+| 关键词 | 许可证类型 |
+|--------|-----------|
+| `Mulan Permissive Software License` | Mulan PSL v2 |
+| `Apache License, Version 2.0` | Apache-2.0 |
+| `Permission is hereby granted, free of charge` (且无 copyleft 条款) | MIT |
+| `GNU GENERAL PUBLIC LICENSE` + `Version 3` | GPL-3.0 |
+| `GNU GENERAL PUBLIC LICENSE` + `Version 2` | GPL-2.0 |
+| `GNU AFFERO GENERAL PUBLIC LICENSE` | AGPL-3.0 |
+| `GNU LESSER GENERAL PUBLIC LICENSE` | LGPL |
+| `Redistribution and use in source and binary forms` + 3 条款 | BSD-3-Clause |
+| `Redistribution and use in source and binary forms` + 2 条款 | BSD-2-Clause |
+| `Mozilla Public License` | MPL-2.0 |
+
+### 3. 声明一致性检查
+
+```bash
+# 检查 package.json 中的 license 字段
+grep -E '"license"\s*:' package.json 2>/dev/null
+
+# 检查 go.mod 是否声明了许可证(Go 社区通常依赖 LICENSE 文件)
+test -f go.mod && echo "Go 项目 — 许可证以 LICENSE 文件为准"
+
+# 检查 npm 子包的 license 声明
+grep -E '"license"\s*:' npm/package.json 2>/dev/null
+```
+
+### 4. 占位符检查
+
+```bash
+# 检查 LICENSE 中是否有未填写的占位符
+grep -n '\[year\]\|\[Year\]\|\[name of copyright holder\]\|\[yyyy\]' LICENSE
+```
+
+### 5. 源码版权头检查
+
+```bash
+# 检查 Go 源文件是否有版权声明(前 10 行)
+find . -name "*.go" -not -path "./vendor/*" | while read f; do
+ if ! head -10 "$f" | grep -qiE 'copyright|license|SPDX-License-Identifier'; then
+ echo "缺少版权头: $f"
+ fi
+done
+```
+
+## 输出示例
+
+```
+== 许可证合规报告 ==
+
+[✓] LICENSE 文件: Mulan PSL v2(存在)
+[✗] npm/package.json: 声明 Apache-2.0(与根 LICENSE 不一致)
+[✗] 占位符: LICENSE 中 [Year] 和 [name of copyright holder] 未填写
+[✗] 源码版权头: 25 个 .go 文件中 25 个缺少版权声明
+```
+
+## Mulan PSL v2 版权头模板
+
+```
+Copyright (c) [Year] [name of copyright holder]
+Mulan Permissive Software License,Version 2
+
+```
+
+每份源文件开头建议附加上述声明。
+
+## 兼容性矩阵
+
+| 项目许可证 | 可使用 MIT | 可使用 Apache-2.0 | 可使用 BSD | 可使用 GPL | 可使用 MulanPSL |
+|-----------|-----------|-------------------|-----------|-----------|----------------|
+| MIT | ✓ | ✓ | ✓ | ✓ | ✓ |
+| Apache-2.0 | ✓ | ✓ | ✓ | ✗ (仅 GPLv3) | ✓ |
+| BSD-3-Clause | ✓ | ✓ | ✓ | ✗ | ✓ |
+| BSD-2-Clause | ✓ | ✓ | ✓ | ✗ | ✓ |
+| Mulan PSL v2 | ✓ | ✓ | ✓ | ✗ | ✓ |
+| GPL-3.0 | ✗ | ✓ | ✗ | ✓ | ✗ |
+| GPL-2.0 | ✗ | ✗ | ✗ | ✓ | ✗ |
+| AGPL-3.0 | ✗ | ✓ (仅 AGPLv3+) | ✗ | ✗ (GPLv3 → AGPLv3 OK) | ✗ |
diff --git a/skills/gitlink-compliance/references/check-pii.md b/skills/gitlink-compliance/references/check-pii.md
new file mode 100644
index 0000000..e21558d
--- /dev/null
+++ b/skills/gitlink-compliance/references/check-pii.md
@@ -0,0 +1,115 @@
+# PII 与暴露面扫描
+
+扫描仓库中可能泄露的个人身份信息(PII)和内部基础设施信息。
+
+**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。**
+
+## PII 扫描规则
+
+### P-001: 邮箱地址
+
+```bash
+# 搜索邮箱地址(排除 vendor 和 node_modules)
+git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \
+ -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \
+ ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+### P-002: 手机号(中国大陆)
+
+```bash
+git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+### P-003: 身份证号(中国大陆)
+
+```bash
+git grep -n -E '\b[0-9]{17}[0-9Xx]\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+## 暴露面扫描规则
+
+### E-001: 内网 IPv4 地址
+
+```bash
+# 搜索内网 IP 地址
+git grep -n -E '\b10\.[0-9]+\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+git grep -n -E '\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+git grep -n -E '\b192\.168\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+### E-002: localhost / 127.0.0.1
+
+```bash
+git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+### E-003: 内部域名 / 测试域名
+
+```bash
+git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+### E-004: .devops 配置中的裸 IP
+
+```bash
+# 检查 CI/CD 配置中的 IP 地址
+find .devops -type f -name '*.yml' -exec grep -n -E '\b[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\b' {} + 2>/dev/null
+```
+
+### E-005: 公网裸 IP
+
+```bash
+# 搜索非 localhost 的公网 IP(排除已知公开 IP 范围)
+git grep -n -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' -- ':/' ':!vendor/' ':!node_modules/' \
+ | grep -v '127\.0\.0\.1' \
+ | grep -v '0\.0\.0\.0' \
+ | grep -v '255\.255\.255\.255' 2>/dev/null
+```
+
+## 已知可忽略项
+
+以下匹配已知不会造成安全问题:
+
+| 文件 | 内容 | 原因 |
+|------|------|------|
+| `npm/package.json` | `support@gitlink.org.cn` | 公开支持邮箱 |
+| `doc/gitlink_api_reference.md` | `yystopf@163.com` | API 文档示例数据 |
+| `doc/gitlink_api_reference.md` | `localhost:3000/api/` | 开发环境 URL |
+| `.devops/gitlink-cli-autodeploy.yml` | `121.41.222.0` | 部署服务器公网 IP |
+
+## 执行完整扫描
+
+```bash
+echo "=== P-001: 邮箱 ==="
+git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \
+ -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \
+ ':!vendor/' ':!node_modules/' ':!doc/' 2>/dev/null
+
+echo "=== P-002: 手机号 ==="
+git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+
+echo "=== E-001: 内网 IP ==="
+git grep -n -E '\b(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+
+echo "=== E-002: localhost ==="
+git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+
+echo "=== E-003: 内部域名 ==="
+git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null
+```
+
+## 排除列表
+
+以下内容不标记:
+
+| 模式 | 原因 |
+|------|------|
+| `user@example.com` | RFC 示例邮箱 |
+| `test@test.com` | 测试用邮箱 |
+| `noreply@*.com` | 无回复邮箱 |
+| `support@*` | 公开支持邮箱 |
+| `gitlink.org.cn` | GitLink 官方公开域名 |
+| `github.com` | GitHub 公开域名 |
+| `cdn.jsdelivr.net` | 公共 CDN |
+| `.github/workflows/` 中的 `${{ secrets.* }}` | CI/CD 变量引用 |
diff --git a/skills/gitlink-compliance/references/check-secrets.md b/skills/gitlink-compliance/references/check-secrets.md
new file mode 100644
index 0000000..7a60e31
--- /dev/null
+++ b/skills/gitlink-compliance/references/check-secrets.md
@@ -0,0 +1,103 @@
+# 敏感信息扫描
+
+扫描仓库中可能存在的硬编码凭据、密钥、Token 等敏感信息。
+
+**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。**
+
+## 扫描规则
+
+### S-001: Token 作为 URL 查询参数
+
+```bash
+# 搜索 access_token 等作为 URL 参数传递的代码
+git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml'
+```
+
+### S-002: 硬编码密码
+
+```bash
+# 搜索硬编码的 password= 或 passwd=
+git grep -n -E 'password\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' '*.json'
+git grep -n -E 'passwd\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py'
+```
+
+### S-003: 硬编码 API Key
+
+```bash
+git grep -n -iE 'api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml'
+```
+
+### S-004: 私钥文件
+
+```bash
+# 搜索私钥内容
+git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null || echo "未发现私钥"
+
+# 搜索私钥文件
+find . -type f \( -name "*.pem" -o -name "*.key" -o -name "*.p12" -o -name "*.pfx" \) \
+ -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null
+```
+
+### S-005: 硬编码 JWT / 长 Token
+
+```bash
+git grep -n -E 'token\s*[:=]\s*"eyJ[A-Za-z0-9_-]{20,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null
+git grep -n -E 'token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"' -- '*.go' '*.js' '*.ts' '*.py' 2>/dev/null
+```
+
+### S-006: 硬编码 Secret
+
+```bash
+git grep -n -iE 'secret\s*[:=]\s*"[^"]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null
+```
+
+### S-007: 凭据配置文件
+
+```bash
+# 搜索可能包含凭据的配置文件
+find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) \
+ -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null
+```
+
+### S-008: Debug 输出中的 Token 泄露
+
+```bash
+git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null
+git grep -n -E 'console\.log.*[Tt]oken' -- '*.js' '*.ts' 2>/dev/null
+```
+
+### S-009: CI/CD 明文 Fallback
+
+```bash
+# 检查 CI/CD 配置中的密钥是否有明文默认值
+grep -n -E 'secrets\.[A-Z_]+\s*\|\|' .github/workflows/*.yml .devops/*.yml 2>/dev/null
+```
+
+### S-010: 数据库连接串
+
+```bash
+git grep -n -E '(mongodb|mysql|postgres|postgresql|redis|jdbc)://[^@]*@' -- '*.go' '*.js' '*.ts' '*.yaml' '*.yml' '*.json' 2>/dev/null
+```
+
+## 执行完整扫描
+
+```bash
+# 汇总执行所有扫描规则
+echo "=== S-001: URL Token 参数 ===" && git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml' 2>/dev/null
+echo "=== S-004: 私钥 ===" && git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null
+echo "=== S-007: 凭据文件 ===" && find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null
+echo "=== S-008: Debug Token ===" && git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null
+echo "=== S-010: 数据库连接串 ===" && git grep -n -E '(mongodb|mysql|postgres|postgresql|redis)://[^@]*@' -- ':/' 2>/dev/null
+```
+
+## 排除列表
+
+以下匹配不被视为安全问题:
+
+| 模式 | 原因 | 示例 |
+|------|------|------|
+| `${{ secrets.XXX }}` | CI/CD 变量引用 | GitHub Actions / DevOps Pipeline |
+| `os.Getenv("XXX")` | 环境变量读取 | `os.Getenv("GITLINK_TOKEN")` |
+| `keyring.Get("xxx")` | OS Keychain 调用 | `keyring.Get("gitlink-cli", "token")` |
+| `--secret` flag 定义 | CLI flag 参数定义 | `cmd.Flags().String("secret", "", "secret")` |
+| 文档中的 `example.com` | 示例域名 | `https://example.com/webhook` |
diff --git a/skills/gitlink-compliance/references/check-sensitive-vocab.md b/skills/gitlink-compliance/references/check-sensitive-vocab.md
new file mode 100644
index 0000000..8b9975c
--- /dev/null
+++ b/skills/gitlink-compliance/references/check-sensitive-vocab.md
@@ -0,0 +1,139 @@
+# 敏感词汇扫描
+
+扫描仓库中可能出现的敏感词汇,包括军事、政务、内部系统等受监管或不宜公开的用语。
+
+**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原文上下文。**
+
+## 敏感词汇分类
+
+### C-001: 军事相关
+
+涉及军事单位、装备、行动等词汇。
+
+搜索模式:
+```
+军|部队|军区|武装|国防|武器|装备|弹药|导弹|雷达|坦克|舰艇|战机|潜艇|航母|核|火箭|弹药库|靶场|兵工厂|军工厂|军事基地|作战|演习|动员|部署|调防|驻地|番号|编制|勤务
+```
+
+```bash
+# 搜索军事敏感词汇
+git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|弹药库|靶场|兵工厂|军工厂|军事基地|作战指挥|军事演习|战备|动员令|兵力部署|调防|驻地|番号|部队编制|后勤保障|勤务' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+### C-002: 政府/党政机关
+
+涉及党政机关、政府内部系统等词汇。
+
+搜索模式:
+```
+中央|国务院|军委|部委|省委|市委|党政|机要|保密|机密|绝密|秘密|内部文件|红头|批复|批示|内参|办公厅|机要局|保密局|国安|公安内网|政务内网|党政机关|公务
+```
+
+```bash
+# 搜索党政敏感词汇
+git grep -n -E '中央委员会|国务院|中央军委|部委|省委|市委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件|内参|批复|批示件|办公厅' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+### C-003: 内部系统标识
+
+涉及内部使用的系统名称、代号、项目名等。
+
+搜索模式:
+```
+内部系统|内部平台|内网|专网|涉密|非密|脱密|密码机|加密机|身份认证|安全审计|堡垒机|防火墙规则|入侵检测|安全监测
+```
+
+```bash
+# 搜索内部系统标识
+git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密处理|密码机|加密机|堡垒机|防火墙规则|入侵检测系统|安全监测平台' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+### C-004: 监管/合规敏感词
+
+涉及金融、医疗、数据隐私等受监管领域。
+
+搜索模式:
+```
+反洗钱|征信|个人隐私|数据出境|跨境传输|敏感数据|涉密数据|关键信息基础设施|网络安全等级保护|等保|密评
+```
+
+```bash
+# 搜索监管敏感词
+git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境数据传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保三级|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+### C-005: 公司/组织敏感信息
+
+涉及内部项目代号、未公开产品名、客户信息等。
+
+搜索模式:
+```
+内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名单|白名单|内部API|私有接口|内部对接
+```
+
+```bash
+# 搜索组织敏感信息
+git grep -n -E '内部代号|项目代号|内部项目名称|未公开|NDA|保密协议|客户名录|内部API地址|私有接口地址|内部对接人|白名单IP' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+### C-006: 密码学/安全产品名称
+
+涉及商用密码产品、安全管控设备等受管制技术。
+
+搜索模式:
+```
+加密算法|国密|SM2|SM3|SM4|SM9|商密|密码模块|密码卡|密码机|VPN|防火墙|入侵防御|WAF|DLP|终端管控|上网行为|日志审计
+```
+
+```bash
+# 搜索密码学/安全产品
+git grep -n -E '国密算法|SM2|SM3|SM4|SM9|商用密码|密码模块|密码卡|密码机|防火墙设备|入侵防御系统|WAF|DLP|上网行为管理|日志审计系统|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+## 执行完整扫描
+
+```bash
+echo "=== C-001: 军事词汇 ==="
+git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null | grep -v 'package-lock' | grep -v '.exe'
+
+echo "=== C-002: 党政词汇 ==="
+git grep -n -E '中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+
+echo "=== C-003: 内部系统 ==="
+git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+
+echo "=== C-004: 监管合规 ==="
+git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+
+echo "=== C-005: 组织敏感 ==="
+git grep -n -E '内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+
+echo "=== C-006: 安全产品 ==="
+git grep -n -E '国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null
+```
+
+## 排除列表
+
+以下匹配不视为敏感:
+
+| 匹配内容 | 原因 |
+|----------|------|
+| `--secret` CLI 参数定义 | 框架定义的参数名,非内容 |
+| `secret_key`/`access_key` field tag | 结构体字段名,非值 |
+| `军队文职/部队文职` 招聘信息 | 公开招录信息 |
+| `国防科技大学` 等公开院校名 | 公开教育机构 |
+| `国家网络安全法` 等法律引用 | 公开法律法规 |
+| `go-keyring` 等依赖名中的 `key` | 第三方包名 |
+| standard library `crypto/*` imports | Go 标准库导入 |
+| `防火墙` 在 IT 产品描述中的正常使用 | 网络安全产品公开描述 |
+
+## 严重度分级
+
+| 类别 | 严重度 | 说明 |
+|------|--------|------|
+| C-001 军事 | **高** | 军事相关内容可能触发合规审查 |
+| C-002 党政 | **高** | 涉及政府内部系统标识 |
+| C-003 内部系统 | 中 | 内部系统信息泄露 |
+| C-004 监管 | 中 | 涉及受监管数据 |
+| C-005 组织 | 低 | 组织内部信息 |
+| C-006 安全产品 | 中 | 安全产品部署细节 |
diff --git a/skills/gitlink-onboard/SKILL.md b/skills/gitlink-onboard/SKILL.md
new file mode 100644
index 0000000..f7838c8
--- /dev/null
+++ b/skills/gitlink-onboard/SKILL.md
@@ -0,0 +1,157 @@
+---
+name: gitlink-onboard
+version: 2.0.0
+description: "新人引导:通过 AI 语义分析识别适合新手的 Good First Issue,调用 CLI 添加引导评论。当用户需要识别新手友好 issue 并欢迎新贡献者时触发。"
+metadata:
+ requires:
+ bins: ["gitlink-cli"]
+ cliHelp: "gitlink-cli onboard --help"
+---
+
+# gitlink-onboard(新人引导)
+
+**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
+**CRITICAL — 写入/删除操作前,务必先确认用户意图。**
+**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。**
+
+> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)
+
+## 运行模式
+
+| 模式 | 说明 | 需要认证 |
+|------|------|----------|
+| AI 分析模式(推荐) | AI 读取所有 open issue,通过语义分析识别 Good First Issue,展示结果后由用户选择并调用 CLI | 是 |
+| 直接模式 | 用户已明确指定 issue 编号,直接调用 `onboard +welcome --issues` | 是 |
+| 标签模式(旧) | 通过 `--tag` 参数按标签过滤 | 是 |
+
+## AI 分析模式工作流(5 步)
+
+当用户说 "帮我识别新人友好 issue" / "good first issue" / "欢迎新人" 等触发词时,
+执行以下 5 步流程:
+
+### 第 1 步:获取所有 Open Issue
+
+```bash
+gitlink-cli issue +list --state open --limit 100 --format json
+```
+
+解析 JSON 输出,提取每个 issue 的 `project_issues_index`(编号)和 `subject`(标题)。
+
+### 第 2 步:逐一获取详情
+
+对每个 issue 调用:
+
+```bash
+gitlink-cli issue +view --number N --format json
+```
+
+提取:标题、描述(body/description)、标签列表。
+
+如果 issue 数量较多(>20),分批处理,每批 10-15 个,先分析标题再决定是否需要完整详情。
+
+### 第 3 步:AI 语义分析
+
+基于 LLM 对 issue 内容的理解,判断是否适合新手。**不使用硬编码规则**,而是基于语义理解。
+
+**正面信号(适合新手):**
+- 标题清晰,范围明确
+- 简单修复类任务(拼写错误、文档补充、配置调整)
+- 分离了多个子任务的复杂大 issue 的子任务
+- 涉及单个文件或少量文件的修改
+- 维护者明确标注了实现方向
+
+**负面信号(不适合新手):**
+- 涉及架构重构或核心模块改动
+- 需要数据库迁移或复杂 SQL 变更
+- 安全相关修复
+- 需要同时修改多个模块
+- 缺乏上下文说明,需求模糊
+- 已有大量评论讨论但无共识
+- 需要深入了解项目内部逻辑
+
+分析每个 issue 后归类为:
+
+| 分类 | 说明 |
+|------|------|
+| `good-first-issue` | 明确适合新手,范围小、有清晰实现路径 |
+| `maybe` | 部分条件符合,但有不确定因素 |
+| `not-recommended` | 不适合新手 |
+
+### 第 4 步:展示结果并确认
+
+以表格形式呈现分析结果:
+
+| # | 标题 | 判断 | 理由 |
+|---|------|------|------|
+| 3 | Fix typo in README | good-first-issue | 文档类、单文件、无依赖 |
+| 7 | Add input validation | good-first-issue | 边界清晰、常见模式 |
+| 12 | Refactor auth module | not-recommended | 核心安全模块、影响面广 |
+| 15 | Update API docs | good-first-issue | 文档补充、无风险 |
+
+然后询问用户选择:
+
+> 共识别 N 个候选 issue。请选择操作:
+> A. 为所有 `good-first-issue` 添加引导评论
+> B. 手动指定(输入 issue 编号,逗号分隔)
+> C. 取消
+
+### 第 5 步:调用 CLI 添加评论
+
+根据用户选择,生成并执行命令:
+
+```bash
+# 选项 A
+gitlink-cli onboard +welcome --issues "3,7,15"
+
+# 选项 B(用户输入 "7,15")
+gitlink-cli onboard +welcome --issues "7,15"
+```
+
+建议首次使用 `--dry-run` 预览:
+
+```bash
+gitlink-cli onboard +welcome --issues "3,7,15" --dry-run
+```
+
+## 直接模式:手动指定 Issue
+
+用户已知 issue 编号时直接调用:
+
+```bash
+# 为指定 issue 添加引导评论
+gitlink-cli onboard +welcome --issues "5,8,12"
+
+# 预览模式
+gitlink-cli onboard +welcome --issues "5,8,12" --dry-run
+
+# 自定义欢迎消息
+gitlink-cli onboard +welcome --issues "5" --template "欢迎新人!请先阅读 README。"
+```
+
+## CLI 工作原理
+
+`onboard +welcome` 命令的两种路径:
+
+1. **`--issues` 路径(优先)**:直接获取指定编号的 issue,跳过标签解析
+2. **`--tag` 路径(向后兼容)**:按标签名过滤 issue
+
+两个路径共享:
+- 每个 issue 检查是否已有引导评论(通过 `` 标记)
+- `--dry-run` 逐条确认
+- 重复运行不重复添加评论
+- 自定义 `--template`(`{login}` 替换为仓库所有者)
+
+## 注意事项
+
+- `--issues` 和 `--tag` 同时指定时,`--issues` 优先生效
+- 需要仓库管理员或 write 权限
+- 建议 AI 分析前先了解项目领域和技术栈,提高判断准确度
+- AI 分析基于 LLM 语义理解,不是硬编码规则——做判断时给出明确的正面/负面理由
+
+## 相关命令
+
+| 命令 | 说明 |
+|------|------|
+| `gitlink-cli issue +list --state open --format json` | 获取所有 open issue |
+| `gitlink-cli issue +view --number N --format json` | 查看 issue 详情 |
+| `gitlink-cli onboard +welcome --issues "N"` | 为指定 issue 添加引导评论 |