新增skill #14

Merged
zzx-coder merged 3 commits from zk_branch into master 2026-06-16 09:40:44 +08:00
17 changed files with 2726 additions and 3 deletions

View File

@ -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
}

View File

@ -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 为 %snpm/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
}

View File

@ -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

View File

@ -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 }

View File

@ -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"
}

View File

@ -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()
}

290
shortcuts/contrib/data.go Normal file
View File

@ -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
}

493
shortcuts/contrib/html.go Normal file
View File

@ -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 = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>贡献者报告 - {{.Owner}}/{{.Repo}}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.card {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 30px;
margin-bottom: 30px;
}
.card h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.chart-container {
width: 100%;
height: 500px;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th, td {
padding: 15px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 1px;
}
tr:hover {
background: #f8f9ff;
}
.rank {
font-weight: bold;
color: #667eea;
font-size: 1.2rem;
}
.rank-1 { color: #FFD700; }
.rank-2 { color: #C0C0C0; }
.rank-3 { color: #CD7F32; }
.score-bar {
background: #e9ecef;
border-radius: 10px;
height: 20px;
overflow: hidden;
}
.score-fill {
background: linear-gradient(90deg, #667eea, #764ba2);
height: 100%;
border-radius: 10px;
transition: width 0.5s ease;
}
.score-text {
font-weight: bold;
color: #667eea;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
margin-right: 10px;
}
.user-info {
display: flex;
align-items: center;
}
.user-name {
font-weight: 600;
color: #333;
}
.user-login {
color: #666;
font-size: 0.9rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 20px;
color: white;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.9rem;
opacity: 0.9;
}
.weight-info {
background: #f8f9ff;
border-radius: 12px;
padding: 20px;
margin-top: 20px;
}
.weight-info h3 {
color: #667eea;
margin-bottom: 15px;
}
.weight-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.weight-item {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: white;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.weight-label {
color: #666;
}
.weight-value {
font-weight: 600;
color: #667eea;
}
@media (max-width: 768px) {
.header h1 {
font-size: 1.8rem;
}
th, td {
padding: 10px 12px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>贡献者报告</h1>
<p>{{.Owner}}/{{.Repo}} - 团队成员贡献分析</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{len .Contributors}}</div>
<div class="stat-label">贡献者总数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-commits">0</div>
<div class="stat-label">总提交数</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-issues">0</div>
<div class="stat-label"> Issue </div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-prs">0</div>
<div class="stat-label"> PR </div>
</div>
</div>
<div class="card">
<h2>贡献占比分布</h2>
<div id="pieChart" class="chart-container"></div>
</div>
<div class="card">
<h2>详细排名</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>排名</th>
<th>成员</th>
<th>Commits</th>
<th>代码行数</th>
<th>Issues</th>
<th>PRs</th>
<th>贡献分数</th>
<th>占比</th>
</tr>
</thead>
<tbody>
{{range .TableRows}}
<tr>
<td class="rank rank-{{.Rank}}">{{.Rank}}</td>
<td>
<div class="user-info">
<div class="avatar">{{slice .Login 0 1}}</div>
<div>
<div class="user-name">{{.Name}}</div>
<div class="user-login">@{{.Login}}</div>
</div>
</div>
</td>
<td>{{.Commits}}</td>
<td>{{.CodeLines}}</td>
<td>{{.Issues}}</td>
<td>{{.PRs}}</td>
<td class="score-text">{{printf "%.4f" .Score}}</td>
<td>
<div class="score-bar">
<div class="score-fill" style="width: {{printf "%.1f" .ScorePct}}%"></div>
</div>
<small>{{printf "%.1f" .ScorePct}}%</small>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>AHP 权重说明</h2>
<div class="weight-info">
<h3>层次分析法 (AHP) 权重分配</h3>
<div class="weight-grid">
<div class="weight-item">
<span class="weight-label">Commits</span>
<span class="weight-value">14.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">代码行数</span>
<span class="weight-value">28.6%</span>
</div>
<div class="weight-item">
<span class="weight-label">PR 合并数</span>
<span class="weight-value">7.1%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 创建</span>
<span class="weight-value">5.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 解决</span>
<span class="weight-value">10.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Release</span>
<span class="weight-value">10.0%</span>
</div>
<div class="weight-item">
<span class="weight-label">Issue 评论</span>
<span class="weight-value">8.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">PR 评审</span>
<span class="weight-value">8.3%</span>
</div>
<div class="weight-item">
<span class="weight-label">Wiki</span>
<span class="weight-value">8.3%</span>
</div>
</div>
</div>
</div>
</div>
<script>
// 计算总数
let totalCommits = 0;
let totalIssues = 0;
let totalPRs = 0;
{{range .Contributors}}
totalCommits += {{.Commits}};
totalIssues += {{.Issues}};
totalPRs += {{.PRs}};
{{end}}
document.getElementById('total-commits').textContent = totalCommits;
document.getElementById('total-issues').textContent = totalIssues;
document.getElementById('total-prs').textContent = totalPRs;
// 初始化饼图
var chart = echarts.init(document.getElementById('pieChart'));
var option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: {
fontSize: 14
}
},
series: [{
name: '贡献占比',
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}\n{d}%',
fontSize: 12
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
data: [
{{range .Contributors}}
{
value: {{printf "%.4f" .Score}},
name: '{{.Login}}'
},
{{end}}
]
}]
};
chart.setOption(option);
// 响应式
window.addEventListener('resize', function() {
chart.resize();
});
</script>
</body>
</html>`

View File

@ -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 = "<!-- 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
}

View File

@ -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 {

View File

@ -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. **行级去重** — 同一行匹配多条规则时,只报告严重度最高的那一条,避免重复报告
## 注意事项
- 只读操作,不修改任何文件
- 敏感信息只报告位置,不展示内容
- 所有扫描在本地完成,不跨仓库
- 部分匹配可能是误报(示例代码、测试数据),需人工判断

View File

@ -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 兼容,无传染风险。
```

View File

@ -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 LicenseVersion 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) | ✗ |

View File

@ -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 变量引用 |

View File

@ -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` |

View File

@ -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 安全产品 | 中 | 安全产品部署细节 |

View File

@ -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 检查是否已有引导评论(通过 `<!-- gitlink-cli:onboard -->` 标记)
- `--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 添加引导评论 |