Merge origin/master into zk_branch, resolve conflicts in milestone/issue/README

This commit is contained in:
Donkey_kevin 2026-07-14 16:41:56 +08:00
commit d9ff6d134d
56 changed files with 8246 additions and 1655 deletions

107
.claude/settings.local.json Normal file
View File

@ -0,0 +1,107 @@
{
"permissions": {
"allow": [
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-common-dir)",
"Bash(git -C \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\" rev-parse --git-dir)",
"Bash(git:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/SKILL.md\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance/references/\"*.md)",
"Bash(go build:*)",
"Bash(go vet:*)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/compliance/\"*.go)",
"Bash(wc -l \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/onboard/\"*.go)",
"Bash(# 复制两个 skill 到全局 agents 目录\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-compliance\" \"C:/Users/Lenovo/.agents/skills/gitlink-compliance\"\ncp -r \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/skills/gitlink-onboard\" \"C:/Users/Lenovo/.agents/skills/gitlink-onboard\"\n\n# 建立符号链接\ncd \"C:/Users/Lenovo/.claude/skills\" && cmd /c \"mklink /D gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\" && cmd /c \"mklink /D gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(cmd.exe /c \"mklink /D C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(powershell.exe -Command \"New-Item -ItemType SymbolicLink -Path 'C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance' -Target 'C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance' -Force\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-compliance C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-compliance\")",
"Bash(cmd.exe /c \"mklink /J C:\\\\Users\\\\Lenovo\\\\.claude\\\\skills\\\\gitlink-onboard C:\\\\Users\\\\Lenovo\\\\.agents\\\\skills\\\\gitlink-onboard\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-compliance\" \"/c/Users/Lenovo/.claude/skills/gitlink-compliance\")",
"Bash(ln -s \"/c/Users/Lenovo/.agents/skills/gitlink-onboard\" \"/c/Users/Lenovo/.claude/skills/gitlink-onboard\")",
"Bash(export GITLINK_TOKEN=\"ce538a7a6b60b189e8ee28c6bf2a2e8186da8141\")",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli auth:*)",
"Bash(gitlink-cli user:*)",
"Bash(gitlink-cli issue:*)",
"Bash(./gitlink-cli.exe onboard:*)",
"Bash(./gitlink-cli.exe compliance:*)",
"Bash(gitlink-cli onboard:*)",
"Bash(./gitlink-cli-tmp.exe onboard:*)",
"Bash(printf \"y\\\\nn\\\\n\")",
"Bash(printf \"y\\\\ny\\\\n\")",
"Bash(\"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/template-13.txt\":*)",
"Bash(jq --version)",
"Bash(for f:*)",
"Bash(do echo:*)",
"Bash(bash -n \"$f\")",
"Bash(done)",
"Bash(bash workflows/01-community-ops.sh --help)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --help)",
"Bash(bash workflows/03-project-init.sh --help)",
"Bash(bash workflows/04-multi-repo-collab.sh --help)",
"Bash(bash workflows/05-contributor-growth.sh --help)",
"Bash(gitlink-cli repo:*)",
"Bash(bash workflows/01-community-ops.sh --owner nudt_zk --repo gitlink-cli --dry-run)",
"Bash(bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash workflows/03-project-init.sh --owner nudt_zk --name test-workflow-03 --description \"Testing workflow 03\" --lang go --dry-run)",
"Bash(bash workflows/04-multi-repo-collab.sh --org nudt_zk --dry-run)",
"Bash(bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(ls:*)",
"Bash(claude --version)",
"Bash(claude -p --output-format json)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe\")",
"Read(//c/Program Files/Git/usr/bin/**)",
"Read(//c/Program Files/Git/bin/**)",
"Bash(export CLAUDE_CODE_GIT_BASH_PATH=\"D:\\\\Git\\\\bin\\\\bash.exe\")",
"Bash(bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 21 --dry-run)",
"Bash(source workflows/lib/common.sh)",
"Bash(echo \"EXIT: $?\")",
"Bash(cp:*)",
"Bash(cmd.exe /c \"mklink /J C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.claude\\\\\\\\skills\\\\\\\\gitlink-research C:\\\\\\\\Users\\\\\\\\Lenovo\\\\\\\\.agents\\\\\\\\skills\\\\\\\\gitlink-research\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/lib\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/templates\")",
"Bash(mkdir -p \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/schemas\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/0\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(cp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/1\"*\"-research\"*\".sh\" \"C:/Users/Lenovo/.agents/skills/gitlink-research/workflows/\")",
"Bash(bash workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(bash -x workflows/11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex)",
"Bash(gitlink-cli pr:*)",
"Bash(gitlink-cli search:*)",
"Bash(gitlink-cli ci:*)",
"Bash(bash -n workflows/06-research-insights.sh)",
"Bash(timeout 60 bash workflows/06-research-insights.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash -n workflows/08-research-compliance.sh)",
"Bash(timeout 90 bash workflows/08-research-compliance.sh --owner zzx-coder --repo gitlink-cli --local-path . --dry-run)",
"Bash(timeout:*)",
"Bash(bash -c 'set -euo pipefail; cand_ids=\"\"; analyzed=0; while IFS=\"\t\" read -r c_owner c_repo; do echo \"loop\"; done <<< \"$cand_ids\"; echo \"done loop\"')",
"Bash(bash -c 'cand_ids=\"\"; while IFS=\" \" read -r line; do echo \"GOT: [$line]\"; done <<< \"$cand_ids\"; echo \"AFTER LOOP\"')",
"Bash(xxd C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh)",
"Bash(xxd)",
"Bash(# Remove debug lines first, then replace here-string with pipe\ncp \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh\" \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/workflows/09-research-collab.sh.bak\"\n# Restore original and make targeted fix\ngit -C \"C:/Users/Lenovo/Desktop/soft运维/gitlink-cli\" checkout -- workflows/09-research-collab.sh 2>/dev/null\n# Now apply all the fixes we discovered)",
"Bash(./gitlink-cli.exe repo:*)",
"Bash(./gitlink-cli.exe api:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe pr:*)",
"mcp__zai-mcp-server__extract_text_from_screenshot",
"mcp__zai-mcp-server__analyze_image",
"Bash(python3 -c \":*)",
"Bash(python -c \":*)",
"Bash(where tesseract:*)",
"Bash(pip list:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/internal -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts -type f -name *.go)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *_test.go -type f)",
"Bash(sed 's/\"\"\"\"//g')",
"Bash(sed 's/CallAPI\\(\"\"\"\"//g')",
"Read(//tmp/**)",
"Bash(go test:*)",
"Bash(go run:*)",
"Bash(gitlink-cli.exe wiki:*)",
"Bash(gitlink-cli board:*)",
"Bash(./gitlink-cli.exe board:*)",
"Bash(where gitlink-cli:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(cd:*)",
"Bash(gitlink-cli file:*)"
]
}
}

View File

@ -23,6 +23,9 @@ jobs:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Run tests
run: go test ./... -cover -race
- name: Build binaries
run: |
mkdir -p dist

View File

@ -15,4 +15,4 @@ clean:
rm -f $(BINARY)
test:
go test ./...
go test ./... -race

888
README.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -119,6 +119,18 @@
.sidebar-section .skills-header {
background: #fdf8ff;
}
.sidebar-section .mission3-header {
background: #eef5fc;
}
.sidebar-section .mission3-header:hover {
background: #dfeaf6;
}
.sidebar-section .mission4-header {
background: #eef7f0;
}
.sidebar-section .mission4-header:hover {
background: #dceee2;
}
/* ── Section collapse (top-level toggle) ── */
.sidebar-section .sidebar-header {
cursor: pointer;
@ -162,6 +174,24 @@
.mode-toggle button:hover:not(.active) {
background:var(--bg-hover); border-color:var(--border-accent);
}
/* ── Agent selector buttons ── */
.agent-selector {
display:flex; gap:0; margin-top:6px;
}
.agent-selector button {
flex:1; padding:5px 8px; border:1px solid var(--border);
background:#fff; color:var(--text-dim); cursor:pointer;
font-size:11px; font-weight:500; transition: all .12s;
}
.agent-selector button:first-child { border-radius:var(--radius-sm) 0 0 var(--radius-sm); }
.agent-selector button:last-child { border-radius:0 var(--radius-sm) var(--radius-sm) 0; }
.agent-selector button:hover:not(.active) {
background:var(--bg-hover); border-color:var(--border-accent);
}
.agent-selector button.active { color:#fff; z-index:1; }
.agent-selector button.agent-claude.active { background:var(--purple); border-color:var(--purple); }
.agent-selector button.agent-codex.active { background:#0ea5e9; border-color:#0ea5e9; }
.agent-selector button.agent-zcode.active { background:#f59e0b; border-color:#f59e0b; }
#sidebar .modules {
padding:4px 0;
}
@ -377,8 +407,8 @@
<div class="sidebar-header" onclick="toggleSection('cli-section')">
<span class="section-arrow"></span> 📋 CLI 命令 · 预设指令
<div class="mode-toggle" onclick="event.stopPropagation()">
<button id="cli-mode-direct" class="active" onclick="setCliExecMode('direct')">⚡ 直接运行</button>
<button id="cli-mode-input" onclick="setCliExecMode('input')">✎ 编辑模式</button>
<button id="cli-mode-direct" class="active" onclick="setSectionExecMode('cli','direct')">⚡ 直接运行</button>
<button id="cli-mode-input" onclick="setSectionExecMode('cli','input')">✎ 编辑模式</button>
</div>
</div>
<div class="modules" id="cli-modules-container">
@ -386,14 +416,18 @@
</div>
</div>
<!-- ── Section 2: Claude Code Skills ── -->
<!-- ── Section 2: Agent Skills ── -->
<div class="sidebar-section" id="skills-section">
<div class="sidebar-header skills-header" onclick="toggleSection('skills-section')">
<span class="section-arrow"></span> 🤖 Claude Code Skills
<span id="session-badge" style="display:none;font-size:10px;background:var(--purple);color:#fff;padding:1px 6px;border-radius:8px;margin-left:6px;" title="会话进行中">● 会话中</span>
<span class="section-arrow"></span> 🤖 AGENT SKILLS
<div class="agent-selector" onclick="event.stopPropagation()">
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
</div>
<div class="mode-toggle" onclick="event.stopPropagation()">
<button id="skills-mode-direct" class="active" onclick="setSkillsExecMode('direct')">⚡ 直接运行</button>
<button id="skills-mode-input" onclick="setSkillsExecMode('input')">✎ 编辑模式</button>
<button id="skills-mode-direct" class="active" onclick="setSectionExecMode('skills','direct')">⚡ 直接运行</button>
<button id="skills-mode-input" onclick="setSectionExecMode('skills','input')">✎ 编辑模式</button>
</div>
</div>
<div class="modules" id="skills-modules-container">
@ -401,6 +435,44 @@
</div>
</div>
<!-- ── Section 3: 自动化控制流(子任务三) ── -->
<div class="sidebar-section" id="mission3-section">
<div class="sidebar-header mission3-header" onclick="toggleSection('mission3-section')">
<span class="section-arrow"></span> 🔧 自动化控制流
<div class="agent-selector" onclick="event.stopPropagation()">
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
</div>
<div class="mode-toggle" onclick="event.stopPropagation()">
<button id="mission3-mode-direct" class="active" onclick="setSectionExecMode('mission3','direct')">⚡ 直接运行</button>
<button id="mission3-mode-input" onclick="setSectionExecMode('mission3','input')">✎ 编辑模式</button>
</div>
</div>
<div class="modules" id="mission3-modules-container">
<!-- filled by JS -->
</div>
</div>
<!-- ── Section 4: GitLink辅助科研场景子任务四 ── -->
<div class="sidebar-section" id="mission4-section">
<div class="sidebar-header mission4-header" onclick="toggleSection('mission4-section')">
<span class="section-arrow"></span> 🔬 GitLink 辅助科研场景
<div class="agent-selector" onclick="event.stopPropagation()">
<button class="agent-btn agent-claude active" data-agent="claude" onclick="setAgent('claude')">🤖 Claude Code</button>
<button class="agent-btn agent-codex" data-agent="codex" onclick="setAgent('codex')">📟 Codex</button>
<button class="agent-btn agent-zcode" data-agent="zcode" onclick="setAgent('zcode')">⚡ ZCode</button>
</div>
<div class="mode-toggle" onclick="event.stopPropagation()">
<button id="mission4-mode-direct" class="active" onclick="setSectionExecMode('mission4','direct')">⚡ 直接运行</button>
<button id="mission4-mode-input" onclick="setSectionExecMode('mission4','input')">✎ 编辑模式</button>
</div>
</div>
<div class="modules" id="mission4-modules-container">
<!-- filled by JS -->
</div>
</div>
</div>
<!-- RESIZER -->
@ -593,7 +665,7 @@ var SKILLS = [
{
id: 'health', icon: '📊', name: '项目健康度报告 (gitlink-health)',
prompts: [
{ label: '基础健康度报告', prompt: '请使用 gitlink-health Skill为 zzx-coder/test-repo 仓库生成项目健康度报告。' },
{ label: '基础健康度报告', prompt: '请使用 gitlink-health Skill为 zzx-coder/test-repo 仓库生成项目健康度报告。\n效率要求重要整份报告须在 2 分钟内完成。计数/合并率/积压数一律从 issue +list、pr +list 的汇总字段一次性获取PR 合并时长只采样「最近 ≤8 条已合并 PR」调用 pr +view 取 merged_at禁止遍历或分页拉取全部历史 PR/Issue。' },
{ label: '安全规则验证:保存路径', prompt: '请把报告保存到桌面上。\n测试仓库zzx-coder/test-repo' },
]
},
@ -640,6 +712,309 @@ var SKILLS = [
];
var MISSION3 = [
{
id: "m3-env",
icon: "🔧",
name: "测试环境准备",
commands: [
{
label: "设置 UTF-8 控制台编码",
engine: "powershell",
cmd: "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\nWrite-Host \"当前控制台编码: $([Console]::OutputEncoding.WebName)\"\nWrite-Host \"(应为 utf-8否则含中文 JSON 解析会失败)\""
},
{
label: "确认 gitlink-cli 版本",
engine: "powershell",
cmd: ".\\gitlink-cli.exe version"
},
{
label: "确认认证状态",
engine: "powershell",
cmd: ".\\gitlink-cli.exe auth status"
},
{
label: "确认 JSON 输出正常",
engine: "powershell",
cmd: ".\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 3 --format json | ConvertFrom-Json"
},
{
label: "加载公共库 common.psm1",
engine: "powershell",
cmd: "Import-Module \".\\workflows\\lib\\common.psm1\" -Force -WarningAction SilentlyContinue\nCheck-Auth"
},
{
label: "确认测试仓库可访问",
engine: "powershell",
cmd: ".\\gitlink-cli.exe repo +info --owner zzx-coder --repo test-repo --format json | ConvertFrom-Json"
}
]
},
{
id: "m3-community",
icon: "🤝",
name: "社区运营自动化 (01-community-ops)",
commands: [
{
label: "基础只读模式验证 (DryRun)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\01-community-ops.ps1 -Owner zzx-coder -Repo test-repo -DryRun"
},
{
label: "查看命令行参数 (Help)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\01-community-ops.ps1 -Help"
},
{
label: "Issue 数据拉取验证",
engine: "powershell",
cmd: "# 拉取 open / closed Issue 与已合并 PR来自测试 2.1.3\n$openResult = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 100 --format json | ConvertFrom-Json\n$closedResult = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state closed --limit 100 --format json | ConvertFrom-Json\n$prResult = .\\gitlink-cli.exe pr +list --owner zzx-coder --repo test-repo --state merged --limit 100 --format json | ConvertFrom-Json\n$prData = if ($prResult.data.issues) { @($prResult.data.issues) } else { @($prResult.data.pulls) }\nWrite-Host \"Open issues: $(@($openResult.data.issues).Count)\"\nWrite-Host \"Closed issues: $(@($closedResult.data.issues).Count)\"\nWrite-Host \"Merged PRs: $($prData.Count)\""
}
]
},
{
id: "m3-gate",
icon: "🛡",
name: "代码质量看门人 (02-code-quality-gatekeeper)",
commands: [
{
label: "审查所有 open PR (DryRun)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -DryRun"
},
{
label: "审查指定 PR #13 (DryRun)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -PrId 13 -DryRun"
},
{
label: "自定义质量阈值 70 (DryRun)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Owner zzx-coder -Repo test-repo -Threshold 70 -DryRun"
},
{
label: "查看帮助信息 (Help)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\02-code-quality-gatekeeper.ps1 -Help"
}
]
},
{
id: "m3-init",
icon: "🚀",
name: "项目一键初始化 (03-project-init)",
commands: [
{
label: "预览模式 (DryRun推荐首选)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\03-project-init.ps1 -Owner zzx-coder -Name test-init-dryrun -Description \"自动化测试仓库\" -Lang go -DryRun"
},
{
label: "底层命令逐步骤验证 (只读安全)",
engine: "powershell",
cmd: "# 底层命令逐步骤验证(只读安全,来自测试 2.3.2\nWrite-Host \"=== Step 1: List repos ===\" -ForegroundColor White\n$repoList = .\\gitlink-cli.exe repo +list --user zzx-coder --limit 5 --format json | ConvertFrom-Json\n$repoCount = if ($repoList.data.projects) { @($repoList.data.projects).Count } else { @($repoList.data).Count }\nWrite-Host \"Repos: $repoCount\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 2: Check Wiki access ===\" -ForegroundColor White\n$wikiResult = .\\gitlink-cli.exe wiki +list --owner zzx-coder --repo test-repo --format json | ConvertFrom-Json\nWrite-Host \"Wiki accessible: $($wikiResult.ok)\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 3: Check branch help ===\" -ForegroundColor White\n.\\gitlink-cli.exe branch --help\n\nWrite-Host \"`n=== Step 4: Check releases ===\" -ForegroundColor White\n$relResult = .\\gitlink-cli.exe release +list --owner zzx-coder --repo test-repo --limit 5 --format json | ConvertFrom-Json\nWrite-Host \"Release API accessible: $($relResult.ok)\" -ForegroundColor Green\n\nWrite-Host \"`n=== Step 5: Check milestone ===\" -ForegroundColor White\n.\\gitlink-cli.exe milestone --help"
},
{
label: "实际创建仓库 (可选·需谨慎)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\03-project-init.ps1 -Owner zzx-coder -Name \"test-init-real\" -Description \"测试仓库-可安全删除\" -Lang go"
}
]
},
{
id: "m3-multirepo",
icon: "🔗",
name: "多仓库协同 (04-multi-repo-collab)",
commands: [
{
label: "生成组织仪表盘",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Org zzx-coder -Output dashboard.html"
},
{
label: "指定仓库列表过滤",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Org zzx-coder -Repos \"test-repo\" -Output dashboard-filtered.html"
},
{
label: "查看帮助和参数",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\04-multi-repo-collab.ps1 -Help"
}
]
},
{
id: "m3-contrib",
icon: "🏆",
name: "贡献者成长体系 (05-contributor-growth)",
commands: [
{
label: "Dry-Run 预览模式",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\05-contributor-growth.ps1 -Owner zzx-coder -Repo test-repo -Sample 5 -DryRun"
},
{
label: "底层数据采集验证",
engine: "powershell",
cmd: "# 底层数据采集验证(来自测试 2.5.2\n$openIssues = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state open --limit 100 --format json | ConvertFrom-Json\n$closedIssues = .\\gitlink-cli.exe issue +list --owner zzx-coder --repo test-repo --state closed --limit 100 --format json | ConvertFrom-Json\n$prsMerged = .\\gitlink-cli.exe pr +list --owner zzx-coder --repo test-repo --state merged --limit 100 --format json | ConvertFrom-Json\n$members = .\\gitlink-cli.exe repo +members --owner zzx-coder --repo test-repo --limit 100 --format json | ConvertFrom-Json\n$prData = if ($prsMerged.data.issues) { @($prsMerged.data.issues) } else { @($prsMerged.data.pulls) }\n$memberData = if ($members.data.members) { @($members.data.members) } else { @($members.data) }\nWrite-Host \"Issues - Open: $(@($openIssues.data.issues).Count), Closed: $(@($closedIssues.data.issues).Count)\" -ForegroundColor Green\nWrite-Host \"Merged PRs: $($prData.Count)\" -ForegroundColor Green\nWrite-Host \"Members: $($memberData.Count)\" -ForegroundColor Green\n$allIssues = @($openIssues.data.issues) + @($closedIssues.data.issues)\n$authors = @{}\nforeach ($issue in $allIssues) {\n $author = if ($issue.author.login) { $issue.author.login } elseif ($issue.author.username) { $issue.author.username } else { $null }\n if ($author) { if (-not $authors.ContainsKey($author)) { $authors[$author] = 0 }; $authors[$author]++ }\n}\nWrite-Host \"Unique contributors: $($authors.Count)\" -ForegroundColor Green\nforeach ($kv in ($authors.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) {\n Write-Host \" @$($kv.Key): $($kv.Value) issues\"\n}"
},
{
label: "颁发徽章 (可选·需谨慎)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\05-contributor-growth.ps1 -Owner zzx-coder -Repo test-repo -Sample 5 -Award"
}
]
},
{
id: "m3-skill",
icon: "🤖",
name: "技能工作流 (Agent · gitlink-workflow)",
prompts: [
{
label: "工作流总入口(触发 7 选项菜单)",
engine: "claude",
prompt: "请帮我执行自动化工作流\n测试仓库zzx-coder/test-repo"
},
{
label: "快捷触发:社区运营",
engine: "claude",
prompt: "帮我跑一下社区运营\n测试仓库zzx-coder/test-repo"
},
{
label: "项目健康度报告 (gitlink-health)",
engine: "claude",
prompt: "请使用 gitlink-health Skill为 zzx-coder/test-repo 仓库生成项目健康度报告。"
},
{
label: "健康度路径规则验证",
engine: "claude",
prompt: "请把健康度报告保存到桌面上。\n测试仓库zzx-coder/test-repo"
},
{
label: "新人引导 AI 分析模式 (onboard)",
engine: "claude",
prompt: "请使用 gitlink-onboard Skill 的 AI 分析模式,扫描 zzx-coder/test-repo 的所有 open Issue\n识别适合新手贡献的 Good First Issue。\n展示分析结果表格后让我选择要对哪些 Issue 添加引导评论。"
},
{
label: "新人引导直接模式 (#11 dry-run)",
engine: "claude",
prompt: "请为 Issue #11 添加新人引导评论,先用 dry-run 预览。\n测试仓库zzx-coder/test-repo"
},
{
label: "FAQ 模式 A生成知识库",
engine: "claude",
prompt: "请使用 gitlink-faq Skill 的模式 A为 zzx-coder/test-repo 生成 Issue 知识库。\n同时采集 open + closed 所有 Issue合并去重逐批读取详情按类型分类和聚类\n生成知识库 Markdown 预览后等我确认再发布到 Wiki。"
},
{
label: "FAQ 模式 B重复检测",
engine: "claude",
prompt: "请检查 zzx-coder/test-repo 有没有和 \"登录功能\" 相关的已有 Issue看看是否有重复的。\n对找到的候选 Issue自动读取详情和 journals给出分析结论。"
},
{
label: "FAQ 模式 CWiki 增量更新",
engine: "claude",
prompt: "请把 Issue #11 补充到已有的 Issue 知识库 Wiki 页面中。\n测试仓库zzx-coder/test-repo"
}
]
},
{
id: "m3-smoke",
icon: "🧪",
name: "端到端冒烟测试 (15 步)",
commands: [
{
label: "运行冒烟测试(连通性验证)",
engine: "powershell",
cmd: "# 端到端冒烟测试(来自测试 5.115 步连通性验证)\n$ErrorActionPreference = \"Continue\"\n$owner = \"zzx-coder\"; $repo = \"test-repo\"\n$pass = 0; $fail = 0\nfunction Test-Step {\n param([string]$Name, [ScriptBlock]$Script)\n Write-Host \"[TEST] $Name ... \" -NoNewline -ForegroundColor Cyan\n try { $result = & $Script; if ($result) { Write-Host \"PASS\" -ForegroundColor Green; $global:pass++ } else { Write-Host \"FAIL\" -ForegroundColor Red; $global:fail++ } }\n catch { Write-Host \"FAIL ($($_.Exception.Message))\" -ForegroundColor Red; $global:fail++ }\n}\nWrite-Host \"`n====== GitLink CLI 子任务三冒烟测试 ======\" -ForegroundColor White\nWrite-Host \"Target: $owner/$repo`n\"\nTest-Step \"Auth status\" { $r = .\\gitlink-cli.exe auth status 2>&1 | Out-String; $r -match \"logged in\" -or $r -match \"token\" }\nTest-Step \"01-Community: issue +list open\" { $r = .\\gitlink-cli.exe issue +list --owner $owner --repo $repo --state open --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"01-Community: pr +list merged\" { $r = .\\gitlink-cli.exe pr +list --owner $owner --repo $repo --state merged --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"02-Gatekeeper: issue +view #11\" { $r = .\\gitlink-cli.exe issue +view --owner $owner --repo $repo --number 11 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"03-Init: repo +list\" { $r = .\\gitlink-cli.exe repo +list --user $owner --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"03-Init: wiki +list\" { $r = .\\gitlink-cli.exe wiki +list --owner $owner --repo $repo --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"04-MultiRepo: org repos\" { $r = .\\gitlink-cli.exe repo +list --user $owner --limit 10 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"04-MultiRepo: release +list\" { $r = .\\gitlink-cli.exe release +list --owner $owner --repo $repo --limit 5 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"05-Contrib: repo +members\" { $r = .\\gitlink-cli.exe repo +members --owner $owner --repo $repo --limit 50 --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"CI: ci +builds\" { $r = .\\gitlink-cli.exe ci +builds --owner $owner --repo $repo --format json | ConvertFrom-Json; $r.ok }\nTest-Step \"Branch: branch help\" { $r = .\\gitlink-cli.exe branch --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"branch\" }\nTest-Step \"Milestone: milestone help\" { $r = .\\gitlink-cli.exe milestone --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"milestone\" }\nTest-Step \"Webhook: webhook help\" { $r = .\\gitlink-cli.exe webhook --help 2>&1 | Out-String; $r -match \"Usage\" -or $r -match \"webhook\" }\nTest-Step \"Research: search +repos\" { $r = .\\gitlink-cli.exe search +repos --q \"agent\" --limit 5 --format json | ConvertFrom-Json; $r.ok }\nWrite-Host \"`n====== Results ======\" -ForegroundColor White\nWrite-Host \" PASS: $pass\" -ForegroundColor Green\nWrite-Host \" FAIL: $fail\" -ForegroundColor Red\nWrite-Host \" TOTAL: $($pass + $fail)\" -ForegroundColor Cyan"
}
]
}
];
var MISSION4 = [
{
id: "m4-insights",
icon: "🔭",
name: "项目洞察 (06-research-insights)",
commands: [
{
label: "生成项目洞悉报告(热度/技术栈/活动)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\06-research-insights.ps1 -Owner zzx-coder -Repo test-repo"
}
]
},
{
id: "m4-compliance",
icon: "✅",
name: "合规与复现性检查 (08-research-compliance)",
commands: [
{
label: "生成 8 维复现性评分卡",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\08-research-compliance.ps1 -Owner zzx-coder -Repo test-repo"
}
]
},
{
id: "m4-progress",
icon: "📈",
name: "进度跟踪与预警 (10-research-progress)",
commands: [
{
label: "生成周度进度报告(含异常预警)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\10-research-progress.ps1 -Owner zzx-coder -Repo test-repo"
}
]
},
{
id: "m4-citation",
icon: "📄",
name: "论文引用生成 (11-research-citation)",
commands: [
{
label: "生成全部 5 种引用格式 (all)",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format all"
},
{
label: "BibTeX 格式",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format bibtex"
},
{
label: "APA 格式",
engine: "powershell",
cmd: "powershell -ExecutionPolicy Bypass -File .\\workflows\\academic\\11-research-citation.ps1 -Owner zzx-coder -Repo test-repo -Format apa"
}
]
},
{
id: "m4-research",
icon: "🔬",
name: "科研辅助 Agent (gitlink-research)",
prompts: [
{
label: "科研功能总入口6 选项菜单)",
engine: "claude",
prompt: "请使用科研辅助功能\n请展示 6 选项菜单:项目洞察 / 热点追踪 / 合规复现 / 协作匹配 / 进度预警 / 论文引用,等我选择)\n测试仓库zzx-coder/test-repo"
},
{
label: "合规与复现性检查",
engine: "claude",
prompt: "请检查 zzx-coder/test-repo 项目的合规与复现性"
},
{
label: "论文引用生成BibTeX",
engine: "claude",
prompt: "请生成 zzx-coder/test-repo 项目的论文引用BibTeX 格式)"
}
]
}
];
// ═══════════════════════════════════════════════
// DOM refs
// ═══════════════════════════════════════════════
@ -650,14 +1025,21 @@ var btnRun = document.getElementById('btn-run');
var btnCancel = document.getElementById('btn-cancel');
var cliModsContainer = document.getElementById('cli-modules-container');
var skillsModsContainer = document.getElementById('skills-modules-container');
var mission3ModsContainer = document.getElementById('mission3-modules-container');
var mission4ModsContainer = document.getElementById('mission4-modules-container');
var statusDot = document.getElementById('status-dot');
var statusText = document.getElementById('status-text');
var toast = document.getElementById('toast');
var isRunning = false;
var currentEngine = 'powershell'; // 'powershell' | 'claude'
var cliExecMode = 'direct'; // CLI section mode
var skillsExecMode = 'direct'; // Skills section mode
var execModes = { cli:'direct', skills:'direct', mission3:'direct', mission4:'direct' }; // 每个 section 的执行模式
var claudeSessionId = null; // Claude Code session ID for multi-turn
var currentAgent = 'claude'; // 'claude' | 'codex' | 'zcode'
var AGENT_META = {
claude: { icon: '🤖', name: 'Claude Code', btnColor: 'var(--purple)' },
codex: { icon: '📟', name: 'Codex', btnColor: '#0ea5e9' },
zcode: { icon: '⚡', name: 'ZCode', btnColor: '#f59e0b' }
};
// ═══════════════════════════════════════════════
// Section Toggle (top-level collapse)
@ -670,7 +1052,7 @@ function toggleSection(sectionId) {
// ═══════════════════════════════════════════════
// Build Sidebar (CLI + Skills sections)
// ═══════════════════════════════════════════════
function buildModuleGroup(container, modules, section, isSkill) {
function buildModuleGroup(container, modules, section) {
if (!container) return;
modules.forEach(function(mod, idx) {
var div = document.createElement('div');
@ -703,23 +1085,19 @@ function buildModuleGroup(container, modules, section, isSkill) {
}
function buildSidebar() {
buildModuleGroup(cliModsContainer, MODULES, 'cli', false);
buildModuleGroup(skillsModsContainer, SKILLS, 'skills', true);
buildModuleGroup(cliModsContainer, MODULES, 'cli');
buildModuleGroup(skillsModsContainer, SKILLS, 'skills');
buildModuleGroup(mission3ModsContainer, MISSION3, 'mission3');
buildModuleGroup(mission4ModsContainer, MISSION4, 'mission4');
}
// ═══════════════════════════════════════════════
// Execution Mode Toggles
// ═══════════════════════════════════════════════
function setCliExecMode(mode) {
cliExecMode = mode;
document.getElementById('cli-mode-direct').className = (mode === 'direct') ? 'active' : '';
document.getElementById('cli-mode-input').className = (mode === 'input') ? 'active' : '';
}
function setSkillsExecMode(mode) {
skillsExecMode = mode;
document.getElementById('skills-mode-direct').className = (mode === 'direct') ? 'active' : '';
document.getElementById('skills-mode-input').className = (mode === 'input') ? 'active' : '';
function setSectionExecMode(section, mode) {
execModes[section] = mode;
document.getElementById(section + '-mode-direct').className = (mode === 'direct') ? 'active' : '';
document.getElementById(section + '-mode-input').className = (mode === 'input') ? 'active' : '';
}
// ═══════════════════════════════════════════════
@ -728,11 +1106,12 @@ function setSkillsExecMode(mode) {
function setEngine(engine) {
currentEngine = engine;
if (engine === 'claude') {
inputPrompt.innerHTML = '🤖&gt;';
var meta = AGENT_META[currentAgent];
inputPrompt.innerHTML = meta.icon + '&gt;';
inputPrompt.title = '点击切换为 PowerShell';
cmdInput.placeholder = '输入 Claude Code 提示词或从左侧 Skills 选择...';
btnRun.textContent = '▶ 执行 (Claude)';
btnRun.style.background = 'var(--purple)';
btnRun.textContent = '▶ 执行 (' + meta.name + ')';
btnRun.style.background = meta.btnColor;
} else {
inputPrompt.innerHTML = 'PS&gt;';
inputPrompt.title = '点击切换为 Claude Code';
@ -746,6 +1125,49 @@ function toggleEngine() {
setEngine(currentEngine === 'powershell' ? 'claude' : 'powershell');
}
// ═══════════════════════════════════════════════
// Prompt helper: update input prompt from AGENT_META
// ═══════════════════════════════════════════════
function updateInputPrompt() {
if (currentEngine === 'claude') {
var meta = AGENT_META[currentAgent];
inputPrompt.innerHTML = meta.icon + '&gt;';
} else {
inputPrompt.innerHTML = 'PS&gt;';
}
}
// ═══════════════════════════════════════════════
// Agent Selector (Skills section)
// ═══════════════════════════════════════════════
function setAgent(agent) {
if (agent === currentAgent) return;
currentAgent = agent;
// Update button active states (支持多 section 共享同一 currentAgent)
var btns = document.querySelectorAll('.agent-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.toggle('active', btns[i].getAttribute('data-agent') === agent);
}
// Clear terminal output (but keep session)
terminal.innerHTML = '';
// Show switch message
var meta = AGENT_META[agent];
appendEntry('info', 'agent已切换为 ' + meta.name);
// Switch engine to Claude mode if not already
if (currentEngine !== 'claude') {
setEngine('claude');
} else {
updateInputPrompt();
}
scrollToBottom();
}
// ═══════════════════════════════════════════════
// Module Toggle
// ═══════════════════════════════════════════════
@ -764,14 +1186,15 @@ function onPresetSelect(selectEl, moduleId) {
return;
}
// Determine section: 'cli' or 'skills'
var isSkill = moduleId.indexOf('skills-') === 0;
// Determine section: 'cli' | 'skills' | 'mission3' | 'mission4'
var section = moduleId.substring(0, moduleId.indexOf('-mod-'));
var dataMap = { cli: MODULES, skills: SKILLS, mission3: MISSION3, mission4: MISSION4 };
var list = dataMap[section] || MODULES;
// Find module
var list = isSkill ? SKILLS : MODULES;
var mod = null;
for (var i = 0; i < list.length; i++) {
if ((isSkill ? 'skills-mod-' : 'cli-mod-') + list[i].id === moduleId) { mod = list[i]; break; }
if (section + '-mod-' + list[i].id === moduleId) { mod = list[i]; break; }
}
if (!mod) return;
@ -779,18 +1202,20 @@ function onPresetSelect(selectEl, moduleId) {
if (!item) return;
var content = item.cmd || item.prompt;
// 每个 item 可自带 engine默认 skills 走 claude其余走 powershell
var engine = item.engine || (section === 'skills' ? 'claude' : 'powershell');
// Show preview
var previewEl = document.getElementById('preview-' + moduleId);
if (previewEl) { previewEl.textContent = content; }
var mode = isSkill ? skillsExecMode : cliExecMode;
var mode = execModes[section] || 'direct';
if (mode === 'direct') {
// Execute immediately
selectEl.value = '';
if (previewEl) { previewEl.textContent = ''; }
if (isSkill) {
if (engine === 'claude') {
setEngine('claude');
executeDirectClaude(content);
} else {
@ -799,7 +1224,7 @@ function onPresetSelect(selectEl, moduleId) {
}
} else {
// Fill input for editing
if (isSkill) { setEngine('claude'); } else { setEngine('powershell'); }
setEngine(engine);
cmdInput.value = content;
autoResizeTextarea();
cmdInput.focus();
@ -832,8 +1257,9 @@ function executeDirectClaude(prompt) {
btnCancel.classList.add('visible');
cmdInput.value = '';
var meta = AGENT_META[currentAgent];
appendEntry('cmd-line',
'<span class="prompt">🤖</span> <span class="cmd-text">' + escHtml(prompt) + '</span>');
'<span class="prompt">' + meta.icon + '</span> <span class="cmd-text">' + escHtml(prompt) + '</span>');
sendClaudeRequest(prompt);
}
@ -852,7 +1278,8 @@ function executeCommand() {
btnRun.classList.add('running');
btnCancel.classList.add('visible');
var promptHtml = isClaude ? '🤖&gt;' : 'PS&gt;';
var meta = AGENT_META[currentAgent];
var promptHtml = isClaude ? meta.icon + '&gt;' : 'PS&gt;';
appendEntry('cmd-line',
'<span class="prompt">' + promptHtml + '</span> <span class="cmd-text">' + escHtml(command) + '</span>');
@ -1040,7 +1467,6 @@ function sendClaudeRequest(prompt) {
// ── 会话管理:保存 session_id 并提示用户 ──
if (data.session_id) {
claudeSessionId = data.session_id;
updateSessionBadge();
var shortId = data.session_id.substring(0, 8);
if (data.ok) {
appendEntry('info', '💬 会话持续中 · ID: <code>' + shortId + '</code> · <span style="color:var(--accent);cursor:pointer;text-decoration:underline;" onclick="newClaudeSession()">点击开始新会话</span> · 下一条 Claude 指令将自动延续本会话');
@ -1074,8 +1500,8 @@ function sendClaudeRequest(prompt) {
resetExecState();
};
// ── 构建请求体:带上会话信息 ──
var reqBody = { prompt: prompt, timeout: 300 };
// ── 构建请求体:带上会话信息(超时 600s = 10min──
var reqBody = { prompt: prompt, timeout: 600 };
if (claudeSessionId) {
reqBody.session_id = claudeSessionId;
}
@ -1085,24 +1511,10 @@ function sendClaudeRequest(prompt) {
// ── Start a fresh Claude Code session ──
function newClaudeSession() {
claudeSessionId = null;
updateSessionBadge();
appendEntry('info', '🆕 已开始新会话 — 下一条 Claude 指令将创建全新对话');
showToast('Claude 会话已重置', 'ok');
}
// ── Update session badge in sidebar ──
function updateSessionBadge() {
var badge = document.getElementById('session-badge');
if (badge) {
if (claudeSessionId) {
badge.style.display = 'inline';
badge.textContent = '● 会话中';
} else {
badge.style.display = 'none';
}
}
}
// ── Reset after execution ──
function resetExecState() {
isRunning = false;
@ -1112,8 +1524,9 @@ function resetExecState() {
cmdInput.focus();
// Restore button text based on current engine
if (currentEngine === 'claude') {
btnRun.textContent = '▶ 执行 (Claude)';
btnRun.style.background = 'var(--purple)';
var meta = AGENT_META[currentAgent];
btnRun.textContent = '▶ 执行 (' + meta.name + ')';
btnRun.style.background = meta.btnColor;
} else {
btnRun.textContent = '▶ 执行';
btnRun.style.background = '';

View File

@ -113,7 +113,7 @@ class DemoHandler(http.server.BaseHTTPRequestHandler):
self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")})
return
timeout = min(data.get("timeout", 300), 600) # max 10 min for Claude
timeout = min(data.get("timeout", 600), 600) # 10 min for Claude
if not CLAUDE_EXE:
self._send_json({
@ -220,6 +220,10 @@ class DemoHandler(http.server.BaseHTTPRequestHandler):
tmp = None
proc = None
try:
# 注入 UTF-8 编码设置:避免 PowerShell 默认 GBK 解码含中文 JSON 导致
# ConvertFrom-Json 报 ArgumentException。对已有该设置的命令重复无害。
command = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + command
tmp = tempfile.NamedTemporaryFile(
mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig"
)

View File

@ -0,0 +1,20 @@
{
"permissions": {
"allow": [
"Bash(go build:*)",
"Bash(/c/Users/Lenovo/Desktop/soft运维/gitlink-cli/gitlink-cli.exe board:*)",
"Bash(go install:*)",
"Bash(gitlink-cli board:*)",
"Bash(/c/Users/Lenovo/go/bin/gitlink-cli.exe board:*)",
"Bash(gitlink-cli repo:*)",
"Bash(gitlink-cli api:*)",
"Bash(gitlink-cli issue:*)",
"Bash(find C:/Users/Lenovo/Desktop/soft运维/gitlink-cli -name *.go -not -path */vendor/* -exec wc -l {} +)",
"Bash(pip install:*)",
"Bash(python gen_docx.py)",
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(ls -la \"C:\\\\Users\\\\Lenovo\\\\Desktop\\\\soft运维\\\\gitlink-cli\\\\doc/\"*.docx)",
"Bash(python gen_ppt.py)"
]
}
}

BIN
doc/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

403
doc/generate_word.py Normal file
View File

@ -0,0 +1,403 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成系统动态模型章节的 Word 文档 - 四个赛题"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def set_cell_shading(cell, color):
"""设置单元格背景颜色"""
shading = OxmlElement('w:shd')
shading.set(qn('w:fill'), color)
cell._tc.get_or_add_tcPr().append(shading)
def add_table(doc, headers, rows, header_color="4472C4"):
"""添加表格"""
table = doc.add_table(rows=len(rows)+1, cols=len(headers))
table.style = 'Table Grid'
table.alignment = WD_TABLE_ALIGNMENT.CENTER
# 表头
for i, header in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = header
set_cell_shading(cell, header_color)
for paragraph in cell.paragraphs:
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in paragraph.runs:
run.font.bold = True
run.font.color.rgb = RGBColor(255, 255, 255)
# 数据行
for i, row in enumerate(rows):
for j, value in enumerate(row):
table.rows[i+1].cells[j].text = value
return table
def create_document():
doc = Document()
# 设置默认字体
style = doc.styles['Normal']
style.font.name = 'Microsoft YaHei'
style._element.rPr.rFonts.set(qn('w:eastAsia'), 'Microsoft YaHei')
# 标题
title = doc.add_heading('系统动态模型', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
# 概述
doc.add_heading('概述', level=1)
doc.add_paragraph(
'系统动态模型描述了 gitlink-cli 在四个赛题场景下的运行时行为,'
'展示了各组件之间的交互流程和数据流向。每个赛题对应一个核心能力方向,'
'覆盖 CLI 功能扩展、Skills 开发、自动化工作流和科研辅助四大领域。'
)
# ==================== 赛题一 ====================
doc.add_heading('子赛题一:增加和完善 GitLink-CLI 能力', level=1)
doc.add_heading('1.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 CLI 命令系统的功能扩展和优化。可构建以下动态模型:'
)
doc.add_paragraph('命令执行模型:描述 Shortcut 命令从参数解析到 API 调用的完整执行流程', style='List Bullet')
doc.add_paragraph('批量操作模型描述批量命令batch-close、batch-create 等)的遍历执行和错误处理机制', style='List Bullet')
doc.add_paragraph('参数校验模型:描述 Choices 枚举校验和 Validate 自定义校验的执行流程', style='List Bullet')
doc.add_paragraph('输出格式化模型:描述 Envelope 数据经过 table/json/yaml 格式化后的输出流程', style='List Bullet')
doc.add_heading('1.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Shortcut', 'wiki +list/+view/+create/+update/+delete', 'Wiki 页面 CRUD 操作'],
['Shortcut', 'webhook +list/+create/+update/+delete/+test', 'Webhook 配置管理'],
['Shortcut', 'board +view/+columns/+issues/+move/+assign', '项目看板操作'],
['Shortcut', 'issue +batch-close/+batch-create/+batch-assign', 'Issue 批量操作'],
['Shortcut', 'repo +batch-create/+batch-update', '仓库批量操作'],
['Shortcut', 'pr +merge (支持 merge/rebase/squash)', 'PR 合并方式选择'],
['Shortcut', 'branch +protect/+unprotect', '分支保护规则管理'],
['Shortcut', 'release +list/+create/+view/+delete', '版本发布管理'],
['结构体', 'Flag.Choices', '枚举参数校验,自动追加 [val1|val2] 提示'],
['结构体', 'Flag.Validate', '自定义校验函数,跨参数约束'],
['结构体', 'PrintOptions', '输出选项:列过滤、禁用截断、彩色输出'],
['函数', 'OpError()', '统一错误构造函数,中英文双语错误信息'],
]
)
doc.add_heading('1.3 交互流程示例', level=2)
doc.add_paragraph('示例一Wiki 页面创建', style='List Bullet')
steps = [
'用户输入gitlink-cli wiki +create --title "API文档" --content "# API Reference"',
'Cobra 解析命令,匹配到 wiki +create Shortcut',
'Validate 校验:检查 --title 是否为空',
'ResolveOwnerRepo() 从 git remote 解析 owner/repo',
'CallAPI("POST", "/wiki/open/createWiki", body) 调用 Gateway API',
'OutputData() 格式化输出创建结果',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:枚举参数校验', style='List Bullet')
steps = [
'用户输入gitlink-cli pr +merge --id 42 --method unknown',
'Cobra 解析参数,发现 --method 值为 unknown',
'Choices 校验unknown 不在 [merge, rebase, squash] 中',
'输出错误invalid value "unknown" for --method有效值: merge, rebase, squash',
'命令终止exit code = 1',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:批量关闭 Issuedry-run 预览)', style='List Bullet')
steps = [
'用户输入gitlink-cli issue +batch-close --numbers 101,102,103 --dry-run',
'解析 --numbers 参数,得到 [101, 102, 103]',
'IsDryRun() == true进入预览模式',
'构建 BatchSummary {dry_run: true, total: 3, results: [{status: "planned"},...]}',
'输出预览结果,不发起 HTTP 请求',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题二 ====================
doc.add_heading('子赛题二:编写和丰富 GitLink Skills', level=1)
doc.add_heading('2.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于 AI Agent Skill 的开发。可构建以下动态模型:'
)
doc.add_paragraph('Skill 加载模型:描述 Claude Code 触发 Skill、加载 SKILL.md 和 reference 文档的流程', style='List Bullet')
doc.add_paragraph('AI 分析模型:描述 AI 对 PR Diff、Issue 内容、仓库数据的分析和决策流程', style='List Bullet')
doc.add_paragraph('自动执行模型:描述 AI 分析结果转化为 CLI 命令执行的流程', style='List Bullet')
doc.add_heading('2.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-code-review', '智能代码审查:分析 PR diff输出结构化 Review 意见'],
['Skill', 'gitlink-issue-triage', 'Issue 自动分拣:根据内容自动分类、打标签、分配责任人'],
['Skill', 'gitlink-changelog', 'Release Notes 生成:根据 commit 和 PR 生成版本说明'],
['Skill', 'gitlink-health', '项目健康度报告:统计 Issue 响应时间、PR 合并效率'],
['Skill', 'gitlink-compliance', '许可证合规检查:扫描许可证合规性和敏感信息泄露'],
['Skill', 'gitlink-onboard', '新人引导:为 good-first-issue 自动添加引导评论'],
['Skill', 'gitlink-stale', '过期 Issue 管理:自动标记和关闭长期未活动的 Issue'],
['Skill', 'gitlink-faq', 'FAQ 自动回复:根据 Issue 内容匹配 FAQ 并自动评论'],
['Reference', 'references/pr-review.md', 'PR 审查参考文档:审查维度、评分标准、评论模板'],
['Reference', 'references/issue-triage.md', 'Issue 分类参考文档:分类规则、标签映射、分配策略'],
]
)
doc.add_heading('2.3 交互流程示例', level=2)
doc.add_paragraph('示例一:智能代码审查', style='List Bullet')
steps = [
'用户触发:"帮我审查 PR #42"',
'Claude Code 加载 gitlink-code-review skill',
'获取 PR 详情gitlink-cli pr +view --id 42 --format json',
'获取变更文件gitlink-cli pr +files --id 42 --format json',
'AI 分析代码:检测未处理 error、SQL 注入风险、性能问题',
'生成审查报告:质量评分 8.5/10列出 3 个问题',
'提交审查评论gitlink-cli pr +review --id 42 --body "..." --event comment',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二Issue 自动分拣', style='List Bullet')
steps = [
'用户触发:"帮我分类本周的 Issue"',
'Claude Code 加载 gitlink-issue-triage skill',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'AI 分析内容识别关键词bug/功能/提问)',
'自动添加标签gitlink-cli issue +label-add --number 101 --labels "缺陷"',
'自动分配责任人gitlink-cli issue +update --number 101 --assignee "zhangsan"',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三Release Notes 生成', style='List Bullet')
steps = [
'用户触发:"帮我生成 v2.1.0 的 Release Notes"',
'Claude Code 加载 gitlink-changelog skill',
'获取已合并 PRgitlink-cli pr +list --state merged --format json',
'获取 commit 历史git log --oneline v2.0.0..HEAD',
'AI 分类整理新功能、Bug 修复、性能优化、Breaking Changes',
'生成 Release Notesgitlink-cli release +create --tag v2.1.0 --body "..."',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题三 ====================
doc.add_heading('子赛题三:构建端到端自动化工作流', level=1)
doc.add_heading('3.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于串联多个步骤的完整解决方案。可构建以下动态模型:'
)
doc.add_paragraph('工作流调度模型:描述工作流从触发、菜单选择、步骤执行到结果输出的完整流程', style='List Bullet')
doc.add_paragraph('多步骤编排模型:描述多个 CLI 命令和 Skill 的串联执行和错误处理', style='List Bullet')
doc.add_paragraph('数据聚合模型:描述从多个数据源采集数据并汇总生成报告的流程', style='List Bullet')
doc.add_heading('3.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-workflows', '工作流调度入口,提供功能菜单选择'],
['Workflow', '01-community-ops.sh', '社区运营自动化Issue 分类 + 周报生成'],
['Workflow', '01a-issue-triage.sh', 'Issue 分类子工作流'],
['Workflow', '01a-webhook-setup.sh', 'Webhook 配置子工作流'],
['Workflow', '02-code-quality-gatekeeper.sh', '代码质量门禁PR 审查 + 评分'],
['Workflow', '03-project-init.sh', '项目一键初始化:仓库 + 文件 + CI + Issues'],
['Workflow', '04-multi-repo-collab.sh', '多仓库协同:依赖追踪 + 协同发版'],
['Workflow', '05-contributor-growth.sh', '贡献者成长体系:评分 + 排行 + Badge'],
['Lib', 'lib/common.sh', '公共函数库:认证检查、错误处理、日志输出'],
]
)
doc.add_heading('3.3 交互流程示例', level=2)
doc.add_paragraph('示例一:项目一键初始化', style='List Bullet')
steps = [
'用户触发:"帮我初始化新项目 my-project"',
'Claude Code 加载 gitlink-workflows skill显示菜单',
'用户选择"项目一键初始化"',
'认证检查gitlink-cli auth status',
'创建仓库gitlink-cli repo +create --name my-project --private',
'初始化文件gitlink-cli file +create --path README.md --content "..."',
'初始化文件gitlink-cli file +create --path .gitignore --content "..."',
'初始化文件gitlink-cli file +create --path LICENSE --content "..."',
'配置分支保护gitlink-cli branch +protect --name master --require-review',
'创建 Issuesgitlink-cli issue +create --title "完善单元测试" --labels "good-first-issue"',
'输出初始化报告',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:多仓库协同发版', style='List Bullet')
steps = [
'用户触发:"帮我管理多仓库协同"',
'扫描仓库列表gitlink-cli repo +list --user my-org --format json',
'分析依赖关系frontend → api-client → backend',
'查询跨仓库 PRgitlink-cli pr +list --state open --format json',
'检查发版就绪gitlink-cli release +list --format json',
'识别阻塞项backend PR #456 未合并',
'生成协同 Dashboard显示各仓库状态、依赖关系、阻塞项',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:贡献者成长体系', style='List Bullet')
steps = [
'用户触发:"帮我生成贡献者排行榜"',
'获取贡献者列表gitlink-cli repo +members --format json',
'统计 PR 活动gitlink-cli pr +list --state merged --format json',
'统计 Issue 活动gitlink-cli issue +list --state closed --format json',
'AI 计算贡献评分PR 数量 × 3 + Issue 数量 × 1 + Review × 2',
'生成排行榜zhangsan(85分) > lisi(72分) > wangwu(68分)',
'自动颁发 Badge为 top 3 贡献者添加 "core-contributor" 标签',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题四 ====================
doc.add_heading('子赛题四:应用 GitLink 辅助科研', level=1)
doc.add_heading('4.1 能构建的模型', level=2)
doc.add_paragraph(
'本赛题聚焦于科研场景的智能化赋能。可构建以下动态模型:'
)
doc.add_paragraph('科研项目洞悉模型:描述从仓库数据提取科研项目信息(技术栈、活跃度、依赖)的流程', style='List Bullet')
doc.add_paragraph('热点追踪模型:描述从 Issue/PR/Commit 数据挖掘科研热点趋势的流程', style='List Bullet')
doc.add_paragraph('合规检查模型:描述许可证合规性扫描和敏感信息检测的流程', style='List Bullet')
doc.add_paragraph('协作匹配模型:描述根据贡献者技能和项目需求进行智能匹配的流程', style='List Bullet')
doc.add_heading('4.2 新生成的组件', level=2)
add_table(doc,
['组件类型', '组件名称', '功能说明'],
[
['Skill', 'gitlink-research', '科研辅助系统总入口,提供科研场景菜单'],
['Skill', 'gitlink-code-insight', '仓库级科研项目洞悉:技术栈、活跃度、依赖分析'],
['Skill', 'gitlink-compliance', '科研项目合规与复现性检查:许可证、依赖、环境'],
['Workflow', '06-research-insights.sh', '科研热点追踪:从 Issue/PR 挖掘研究趋势'],
['Workflow', '08-research-compliance.sh', '科研合规检查:扫描许可证和敏感信息'],
['Workflow', '10-research-progress.sh', '科研进度跟踪:里程碑进度、阻塞项识别'],
['Workflow', '11-research-citation.sh', '论文引用分析:追踪仓库的学术引用情况'],
]
)
doc.add_heading('4.3 交互流程示例', level=2)
doc.add_paragraph('示例一:仓库级科研项目洞悉', style='List Bullet')
steps = [
'用户触发:"帮我分析这个仓库的科研价值"',
'Claude Code 加载 gitlink-code-insight skill',
'获取仓库信息gitlink-cli repo +info --format json',
'分析技术栈:扫描 package.json/go.mod/requirements.txt',
'分析活跃度gitlink-cli pr +list --state merged --format json',
'分析依赖:识别上下游依赖关系',
'生成科研洞悉报告:技术栈、活跃贡献者、核心模块、研究方向',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例二:科研热点追踪', style='List Bullet')
steps = [
'用户触发:"帮我追踪 AI 领域的科研热点"',
'扫描相关仓库gitlink-cli search +repos --query "machine-learning"',
'获取 Issue 列表gitlink-cli issue +list --state open --format json',
'获取 PR 列表gitlink-cli pr +list --state merged --format json',
'AI 分析关键词识别高频技术词汇transformer、diffusion、LLM',
'生成热点报告:技术趋势、热门项目、活跃研究者',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
doc.add_paragraph('示例三:科研合规与复现性检查', style='List Bullet')
steps = [
'用户触发:"帮我检查这个科研项目的合规性"',
'Claude Code 加载 gitlink-compliance skill',
'扫描许可证:检查 LICENSE 文件和依赖许可证',
'扫描敏感信息:检查 API Key、密码、私钥泄露',
'检查复现性:验证 requirements.txt/go.mod 完整性',
'生成合规报告:许可证合规、依赖风险、复现性评分',
]
for i, step in enumerate(steps, 1):
doc.add_paragraph(f' {i}. {step}')
# ==================== 赛题对比总结 ====================
doc.add_heading('赛题对比总结', level=1)
add_table(doc,
['维度', '赛题一CLI 能力', '赛题二Skills 开发', '赛题三:自动化工作流', '赛题四:辅助科研'],
[
['核心目标', '扩展 CLI 命令', '开发 AI Skill', '串联完整流程', '科研场景赋能'],
['技术栈', 'Go + Cobra', 'Markdown + CLI', 'Shell + CLI + Skill', '数据分析 + AI'],
['交付物', 'Shortcut + 结构体', 'SKILL.md + Reference', 'Workflow 脚本', '科研 Skill + 报告'],
['AI 参与', '', '核心AI 分析)', '调度 + 分析', '深度(知识挖掘)'],
['典型场景', 'Wiki/Webhook/批量', '代码审查/Issue 分类', '项目初始化/协同发版', '热点追踪/合规检查'],
]
)
# ==================== 组件依赖关系 ====================
doc.add_heading('组件依赖关系', level=1)
dep_tree = """gitlink-cli (CLI 核心)
Shortcuts (赛题一)
wiki +list/+view/+create/+update/+delete
webhook +list/+create/+update/+delete/+test
board +view/+columns/+issues/+move/+assign
issue +batch-close/+batch-create/+batch-assign
repo +batch-create/+batch-update
branch +protect/+unprotect
Skills (赛题二)
gitlink-code-review (代码审查)
gitlink-issue-triage (Issue 分类)
gitlink-changelog (Release Notes)
gitlink-health (项目健康度)
gitlink-compliance (合规检查)
gitlink-onboard (新人引导)
gitlink-stale (过期管理)
Workflows (赛题三)
01-community-ops.sh (社区运营)
02-code-quality-gatekeeper.sh (质量门禁)
03-project-init.sh (项目初始化)
04-multi-repo-collab.sh (多仓库协同)
05-contributor-growth.sh (贡献者成长)
Research Skills (赛题四)
gitlink-research (科研总入口)
gitlink-code-insight (项目洞悉)
gitlink-compliance (合规检查)
research workflows (热点/进度/引用)"""
p = doc.add_paragraph()
run = p.add_run(dep_tree)
run.font.name = 'Consolas'
# ==================== 状态转换说明 ====================
doc.add_heading('状态转换说明', level=1)
doc.add_heading('Shortcut 命令执行状态', level=2)
doc.add_paragraph('Init → Parsing → Validation → Resolving → Loading → Executing → APICall → Done/Error')
doc.add_paragraph('任何阶段出现错误都会进入 Error 状态,输出结构化错误信息后终止。')
doc.add_heading('Skill 触发状态', level=2)
doc.add_paragraph('Idle → Triggered → Loaded → AuthCheck → DataCollection → AIAnalysis → Execution → Output')
doc.add_paragraph('Skill 加载失败时回退到 Idle 状态AI 分析失败时输出错误提示。')
doc.add_heading('Workflow 执行状态', level=2)
doc.add_paragraph('Idle → Selecting → Loading → AuthCheck → Step1 → Step2 → ... → StepN → Done')
doc.add_paragraph('任一步骤失败时根据配置决定继续或终止,最终输出执行汇总。')
# 保存
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\系统动态模型章节.docx'
doc.save(output_path)
print(f'Word 文档已生成:{output_path}')
if __name__ == '__main__':
create_document()

View File

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
"Bash(pip install:*)",
"Bash(python generate_report.py)"
]
}
}

56
doc/作业要求.txt Normal file
View File

@ -0,0 +1,56 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能|难度:中高|需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
●新增Shortcut命令(如Wiki管理、Webhook配置、项目看板增强、代码片段管理等)
●优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力(如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全Raw API封装(对齐GitLink OpenAPI中尚未封装的接口)
子赛题二编写和丰富GitLink Skills
定位开发Agent Skill|难度:中|无需Go,Markdown +CLI调用即可
基于gitlink-cli开发新的AI Agent Skill。核心交付物是Skill本身(SKILLmd+使用示例+Agent平台验证结果)。场景示例(不限于此):
·智能代码审查分析PR diff,输出结构化Review意见并自动评论
·Issue自动分拣根据内容自动分类、打标签、分配责任人
·Release Notes生成根据commit和PR记录生成结构化版本说明
·项目健康度报告统计Issue响应时间、PR合并效率、贡献者活跃度
·许可证合规检查:扫描仓库的许可证合规性和敏感信息泄露风险
·新人引导为good-first-issue自动添加引导评论降低新贡献者参与门槛
子赛题三:构建端到端自动化工作流
定位:组合现有能力解决实际问题丨难度:低-中「无需写底层代码
组合gitlink-cli已有命令和Skills可包含自定义Skill完成一个可复现的端到端自动化场景。与子赛题二的区别在于子赛题二
交付的是独立可复用的Skill子赛题三交付的是串联多个步骤的完整解决方案。
场景示例(不限于此):
·项目一键初始化输入项目描述→创建仓库→生成README/LICENSE/CI配置→创建初始IsSUe和里程碑
·多仓库协同跨多个仓库的统一Issue追踪、PR状态看板、Release协调发布
·贡献者成长体系追踪贡献者的PR/Issue活动→生成贡献排行→自动颁发徽章
子赛题四:应用 GitLink 辅助科研(可选完成,视完成情况额外加分)
 定位:科研场景智能化赋能 | 难度:中 | 要求:无需 GoMarkdown + CLI 调用即可
 任务:依托 gitlink-cli 数据获取、命令调用与 AI Agent 能力融合数据分析、知识图谱、AI 挖掘技术,面向
科研工作者、课题组与科研团队,将 GitLink 平台代码托管、协作数据转化为科研创新支撑能力,实现科研项目
分析、主体画像、热点追踪、创新启发、合规校验等全链路科研辅助服务,打通开源代码生态与学术科研的融
合通道,包括但不限于以下场景:
• 仓库级科研项目洞悉
• 科研热点追踪与知识图谱构建
• 科研项目合规与复现性检查
• 科研协作智能匹配
• 科研进度智能跟踪与预警

View File

@ -0,0 +1,294 @@
# 子任务一CLI 命令系统优化
## PR: feat: CLI 命令系统优化 — 参数校验、输出格式、错误提示、帮助文档
### Summary
本次优化针对 gitlink-cli 的命令系统进行了四个方面的改进,提升用户体验和开发者效率:
1. **参数设计** — 新增 `Choices` 枚举校验和 `Validate` 自定义校验,非法参数在本地拦截而非等待 API 返回 422
2. **输出格式** — 新增 `--columns`、`--no-truncate`、`--no-color` 全局 flag支持列过滤、禁用截断、彩色表头
3. **错误提示** — 统一 `OpError` 构造函数,错误信息包含英文 `message`(脚本可解析)+ 中文 `suggestion`(用户可读)
4. **帮助文档**`Shortcut` 新增 `Long`/`Example` 字段15 个 Group 命令补充详细说明,新增 `completion` 子命令
---
### 一、参数设计优化
#### 问题
- 枚举参数无校验,非法值直接发往 API等服务端返回 422 才知道参数错误
- 跨参数约束散落在 `Run()` 函数中,格式不统一,中英文混杂
#### 解决方案
**1. Choices 枚举校验**
```go
// Flag 结构体新增 Choices 字段
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// 命令中声明 Choices
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 自动追加 `[merge|rebase|squash]`
**2. Validate 自定义校验**
```go
// Shortcut 结构体新增 Validate 字段
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
```
**3. 统一错误格式**
```go
// 使用 clierrors.InputError 统一错误格式
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
### 二、输出格式优化
#### 问题
- 表格输出列全量显示,无法过滤
- 长文本在 60 字符处硬截断,不可配置
- 无色彩区分,可读性差
#### 解决方案
**1. PrintOptions 结构体**
```go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
```
**2. 新增全局 Flag**
```bash
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
**3. 默认格式修正**
`--format` 默认值从 `json` 改为 `table`,与 `--help` 文本一致。
---
### 三、错误提示优化
#### 问题
- 中英文混用,格式不统一
- `fmt.Errorf` 不被框架识别,无法输出结构化错误
#### 解决方案
**1. OpError 统一构造函数**
```go
// 入参只需动词和资源名
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
// 命令中一行调用
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
```
**2. 结构化输出**
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
### 四、帮助文档优化
#### 问题
- Shortcut 无详细帮助,`--help` 只有命令行和 flag 列表
- Group 命令只有一行描述
- 无 Shell 自动补全
#### 解决方案
**1. Shortcut 新增 Long/Example 字段**
```go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// 命令中填写 Example
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
}
```
**2. 15 个 Group 命令补充 Long/Example**
```go
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
```
**3. 新增 completion 子命令**
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
### Modified Files
| 文件 | 改动 |
|------|------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate/Columns/NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut/Flag/RuntimeContext 结构体扩展 |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 |
| `internal/output/formatter.go` | PrintOptions + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long/Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/pr/pr.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/repo/repo.go` | 错误统一 + Choices + Long/Example |
| `shortcuts/milestone/milestone.go` | 修复 milestone +view 输出问题 |
| `shortcuts/board/board.go` | 修正 assign 字段 |
---
### Testing
- `go build` 通过
- `go vet` 通过
- `go test ./...` 7 个测试包全部通过
- 所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译
---
### Breaking Changes
无。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
---
### 验证示例
**1. 枚举校验**
```bash
$ gitlink-cli pr +merge --id 42 --method unknown
Error: invalid value "unknown" for --method
有效值: merge, rebase, squash。运行 'gitlink-cli pr +merge --help' 查看用法。
```
**2. 跨参数约束**
```bash
$ gitlink-cli issue +update --number 42
Error: at least one of --title, --body, or --state is required
至少需要提供 --title、--body 或 --state 中的一个参数
```
**3. 列过滤**
```bash
$ gitlink-cli issue +list --columns id,subject,status
id subject status
-- ------- ------
42 Fix login bug open
43 Add unit tests closed
```
**4. 结构化错误**
```bash
$ gitlink-cli issue +view --number 999999 --format json
{
"ok": false,
"error": {
"code": 404,
"message": "failed to view issue",
"suggestion": "查看失败,请确认资源 ID 是否存在"
}
}
```
**5. Shell 自动补全**
```bash
$ source <(gitlink-cli completion bash)
$ gitlink-cli pr +<TAB>
+list +create +view +merge +close +reopen +update +files +diff +comment
```

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,408 @@
目录
1. 项目概述
2、软件总体设计
2.1 软件体系结构设计
2.2 用户界面设计
2.3 数据库设计
2.4 系统度量
3、系统静态模型
3.1 命令框架层设计
3.2 快捷命令层设计
3.3 核心库层设计
4、系统动态模型
4.1 用例一:用户认证与仓库操作
4.2 用例二Issue 批量管理
4.3 用例三AI 自动化工作流
5、系统部署模型
1. 项目概述
gitlink-cli 是 GitLink确实开源平台的命令行工具。GitLink 是 CCF 官方开源协作平台,后端提供 490+ API 端点,但此前缺少官方 CLI 工具。本项目填补了这个空白。
应用背景:
开发者在日常工作中需要频繁操作仓库、Issue、PR、分支等资源。浏览器操作效率低无法批量处理也不能与 CI/CD 流水线集成。gitlink-cli 让开发者在终端中完成所有平台操作,并通过 AI Agent Skill 支持 Claude Code 自动化执行复杂工作流。
功能描述:
- 仓库管理创建、列表、详情、Fork、删除、设置、批量操作
- Issue 管理列表、创建、查看、更新、关闭、评论、标签、6 种批量命令
- PR 管理:列表、创建、查看、合并、关闭、代码审查、变更文件查看
- 分支管理:列表、创建、删除、保护
- 发布管理:列表、创建、查看、删除
- Wiki 管理:列表、创建、查看、更新、删除
- Webhook 管理:列表、创建、更新、删除、测试
- CI/CD 管理:构建列表、日志、重启、停止
- 组织与团队管理:组织列表、详情、成员、团队 CRUD
- 合规检查:许可证扫描、敏感信息检测、依赖审计
- 搜索:仓库搜索、用户搜索
- AI 工作流:社区运营、代码审查、项目初始化、多仓库协同、贡献者成长
性能要求:
- 单次 API 调用响应时间 < 2 秒
- 批量操作支持并发执行,错误不中断
- 分页自动遍历,支持大数据量场景
- 跨平台支持Windows、macOS、Linux
2、软件总体设计
2.1 软件体系结构设计
本项目采用分层架构,从上到共分为五层:
1入口层main.go 和 cmd/root.go基于 Cobra 框架构建命令树。
2命令层cmd/ 目录,包含 auth、api、config、compliance 四个顶级命令。
3快捷命令层shortcuts/ 目录16 个资源组,每组通过 +动词 子命令暴露操作。
4核心库层internal/ 目录,包含 HTTP 客户端、认证、配置、输出格式化、错误处理。
5AI 扩展层skills/ 目录24 个 SKILL.md和 workflows/ 目录bash/PowerShell 脚本)。
各层的职责划分:
[入口层] main.go → cmd.Execute() → Cobra root 命令
[命令层] auth / api / config / compliance
[快捷命令层] shortcuts/ → repo/issue/pr/wiki/... → common.Shortcut 声明式定义
[核心库层] internal/client → HTTP 请求 → internal/auth → Token 注入
internal/output → 格式化输出JSON/Table/YAML
internal/config → 配置文件读写
internal/context → git remote 解析 owner/repo
[AI 扩展层] skills/ → SKILL.md 定义(供 Claude Code 调用)
workflows/ → 可执行脚本bash/PowerShell
核心设计模式是声明式 Shortcut 框架。每个快捷命令通过 common.Shortcut 结构体定义 Name、Flags、Run 函数,由 runner.go 统一挂载到 Cobra 命令树。开发者新增命令只需实现 Run 函数,不需要关心命令注册和参数解析的细节。
三层命令体系:
- Layer 1 Shortcuts语义化封装覆盖 16 个领域共 80+ 命令(如 issue +create
- Layer 2 API Commands原始 HTTP 调用api GET/POST/PUT/DELETE
- Layer 3 Raw API覆盖全部 490+ 端点,自动注入认证 Header
2.2 用户界面设计
本项目是 CLI 工具,用户界面是终端命令行。界面设计遵循以下原则:
1命令格式统一gitlink-cli <资源> +<动作> [flags]
例如gitlink-cli issue +create --title "Bug" --body "描述"
2输出格式三选一通过 --format 参数选择 json、table、yaml默认 table。
table 格式使用 tabwriter 对齐,支持 ANSI 颜色高亮表头。
json 格式使用统一的 Envelope 结构:{ok, data, error, meta}。
3全局参数--owner、--repo自动从 git remote 解析)、--format、--debug、--no-truncate、--columns、--no-color。
4Shell 补全:支持 bash、zsh、fish、powershell 四种 shell 的自动补全。
5帮助系统每个命令支持 --help显示用法、参数说明和示例。
用户操作流程:
用户打开终端 → 输入 gitlink-cli auth login 登录 → 使用具体命令操作资源
→ 输出结果以 table/json/yaml 格式显示 → 可通过管道传递给其他工具
2.3 数据库设计
本项目不使用传统数据库。数据存储分为两部分:
1配置文件存储
路径:~/.config/gitlink-cli/config.yaml
内容base_urlAPI 地址、default_format输出格式、editor、pager
格式YAML
2凭证存储
使用 go-keyring 库,调用操作系统原生密钥管理:
- macOSKeychain
- LinuxSecret ServiceGNOME Keyring / KDE Wallet
- WindowsCredential Manager
Fallback~/.config/gitlink-cli/credentials文件权限 0600
存储的数据结构:
Token字符串→ 关联 gitlink.org.cn 的 Bearer Token
ConfigYAML→ base_url、default_format、editor、pager 等配置项
本项目不需要关系型数据库,所有数据来自 GitLink 平台 API 的实时查询。
2.4 系统度量
1代码规模
- Go 源文件60 个
- Go 代码总行数14,932 行
- 测试文件12 个,共 4,279 行
- 测试覆盖率:测试代码占总代码的 22.3%
2按模块统计
模块 文件数 代码行数
main入口 1 13
cmd命令层 5 379
internal核心库 13 2,242
shortcuts快捷层 41 12,398
合计 60 14,932
3功能规模
- Shortcut 命令组16 个
- Shortcut 命令总数80+
- AI Skill 定义24 个 SKILL.md
- 自动化工作流脚本15+ 个bash/PowerShell
- 支持的 Shell 补全4 种bash/zsh/fish/powershell
4依赖规模
- 直接依赖4 个cobra、go-keyring、golang.org/x/term、yaml.v3
- 编译产物大小:约 11.9 MB单二进制文件
3、系统静态模型
3.1 命令框架层设计
命令框架层由 cmd/ 包和 shortcuts/common/ 包组成,定义了整个 CLI 的骨架。
核心类结构:
1cobra.Command来自 spf13/cobra 库)
- 每个命令对应一个 cobra.Command 实例
- rootCmd 是根命令,所有子命令通过 AddCommand 挂载
- root.go 定义全局 PersistentFlags--owner、--repo、--format 等)
2common.Shortcut 结构体:
type Shortcut struct {
Name string
Description string
Long string
Example string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Validate func(args map[string]string) error
Run func(ctx *RuntimeContext) error
}
3common.Flag 结构体:
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string
Validate func(value string) error
}
4common.RuntimeContext 结构体:
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
NoTruncate bool
Columns string
NoColor bool
}
RuntimeContext 提供以下核心方法:
- ResolveOwnerRepo():从 git remote 或 flags 解析 owner/repo
- CallAPI(method, path, body):发起 HTTP 请求
- PaginateAll(path, params):自动遍历分页
- Output(env):格式化输出
- OutputData(data):封装为成功 Envelope 并输出
5register.go 的注册机制:
RegisterAll(root) 遍历 16 个命令组,每组创建 cobra.Command 并调用 MountShortcuts 挂载子命令。MountShortcuts 遍历 Shortcut 切片,为每个 Shortcut 创建对应的 cobra.Command绑定 flags 和 RunE 函数。
3.2 快捷命令层设计
快捷命令层位于 shortcuts/ 目录,包含 16 个命令组。每个组是一个独立的 Go 包,通过 Shortcuts() 函数返回 []*common.Shortcut 切片。
命令组列表:
- board看板操作view/columns/issues/move/assign/stats— 832 行
- branch分支操作list/create/delete/protect— 157 行
- ciCI/CD 操作builds/logs/restart/stop— 117 行
- file文件操作ls/read/create/update/delete/commits/diff— 886 行
- issueIssue 操作14 个命令,含 6 种批量操作)— 3,058 行
- milestone里程碑操作list/create/view/update/delete/status— 525 行
- org组织操作list/info/members/create/update— 476 行
- prPR 操作list/create/view/merge/close/review/files/diff— 973 行
- release发布操作list/create/view/delete— 179 行
- repo仓库操作含 batch-create/batch-update/batch-delete/batch-member— 1,781 行
- search搜索操作repos/users— 88 行
- team团队操作list/create/delete/members— 192 行
- user用户操作me/info— 41 行
- webhookWebhook 操作list/create/view/update/delete/test/events— 649 行
- wikiWiki 操作list/create/view/update/delete/lint/fix/sync— 1,612 行
最大的两个模块是 issue3,058 行)和 wiki1,612 行。issue 模块包含批量操作功能wiki 模块处理双域名架构和 base64 编解码。
批量操作设计模式(以 issue 为例):
- BatchResult单条操作结果number/action/status/error
- BatchSummary汇总信息total/succeeded/failed/results
- 输入灵活:--numbers内联和 --fromCSV 文件)可同时使用
- dry-run 统一:所有批量命令支持 --dry-run 预览
- 错误不中断:单条失败不影响后续处理
3.3 核心库层设计
核心库层位于 internal/ 目录,包含 7 个子包:
1internal/auth — 认证模块3 个文件272 行)
- login.go登录流程用户名密码或 Token 粘贴)
- token_store.goToken 存储go-keyring 跨平台支持)
- transport.goHTTP Transport自动在请求 Header 中注入 Bearer Token
2internal/client — HTTP 客户端2 个文件319 行)
- client.go封装 Get/Post/Put/Delete 方法,自动追加 .json 后缀双重错误检查HTTP 状态码 + JSON body 中的 status 字段)
- pagination.go分页迭代器自动遍历 Kaminari 风格分页
3internal/compliance — 合规检查4 个文件685 行)
- cmd.gocompliance 子命令定义
- scanner.go扫描器实现
- license.go许可证检测
- rules.go合规规则定义
4internal/config — 配置管理1 个文件125 行)
- config.go读写 ~/.config/gitlink-cli/config.yaml
5internal/context — 上下文解析1 个文件91 行)
- repo.go从 git remote 的 origin URL 自动解析 owner 和 repo
6internal/errors — 错误处理1 个文件150 行)
- errors.go统一错误类型 CLIError包含 Kind错误分类、Message、Suggestion、Command 字段
- 错误分类KindAuth、KindInput、KindForbidden、KindNotFound、KindServer、KindUnknown
7internal/output — 输出格式化3 个文件425 行)
- envelope.go定义 Envelope 结构体 {OK, Data, Error, Meta}
- formatter.go三种输出格式JSON/YAML/Tabletable 使用 tabwriter 对齐,支持 ANSI 颜色、列过滤、值截断控制
4、系统动态模型
4.1 用例一:用户认证与仓库操作
用例描述:用户首次使用 gitlink-cli完成登录并查看仓库信息。
参与者:开发者(终端用户)
前置条件:已安装 gitlink-cli网络可访问 gitlink.org.cn
主要流程:
1用户执行 gitlink-cli auth login
2系统提示输入用户名和密码
3系统调用 POST /api/accounts/login 获取 Token
4Token 存入 OS Keychain
5用户进入 git 仓库目录,执行 gitlink-cli repo +info
6系统从 .git/config 解析 remote origin URL提取 owner/repo
7系统调用 GET /api/{owner}/{repo}/detail.json
8系统以 table 格式输出仓库信息
异常流程:
- Token 过期7 天有效期):系统返回 401 错误,提示运行 gitlink-cli auth login 重新登录
- 非 git 目录:系统提示使用 --owner 和 --repo 参数显式指定
API 调用序列:
POST /api/accounts/login → {token: "..."}
GET /api/{owner}/{repo}/detail.json → {name, description, default_branch, ...}
4.2 用例二Issue 批量管理
用例描述:项目维护者批量关闭过期 Issue 并批量创建新 Issue。
参与者:项目维护者
前置条件:已登录,有仓库写权限
主要流程:
1用户执行 gitlink-cli issue +batch-close --numbers 101,102,103,104 --dry-run
2系统预览显示 4 个 Issue 将被关闭,不实际执行
3用户确认后去掉 --dry-run重新执行
4系统逐个调用 PUT /api/{owner}/{repo}/issues/{number},设置 state 为 closed
5系统输出 BatchSummarytotal=4, succeeded=4, failed=0
6用户准备 CSV 文件 issues.csv
title,body,priority,label
"登录页面样式异常","点击按钮后样式错乱","high","缺陷"
"新增数据导出功能","支持 CSV 和 JSON 格式导出","normal","功能"
7用户执行 gitlink-cli issue +batch-create --from issues.csv --template bug
8系统逐个调用 POST /api/{owner}/{repo}/issues 创建 Issue
9系统输出 BatchSummarytotal=2, succeeded=2, failed=0
异常流程:
- 单条操作失败:记录错误,继续处理后续条目,最终 exit code = 1
- API 字段静默失败GitLink 对不认识的字段返回 200 而非报错,需通过浏览器 DevTools 确认字段名
4.3 用例三AI 自动化工作流
用例描述:使用 Claude Code 和 gitlink-cli Skill 自动完成社区运营任务。
参与者:社区运营者(通过 Claude Code 交互)
前置条件Claude Code 已安装gitlink-cli 已登录
主要流程:
1用户在 Claude Code 中说"帮我跑社区运营工作流"
2Claude Code 触发 gitlink-workflows skill显示功能菜单
3用户选择"社区运营自动化"
4Claude Code 调用 gitlink-cli issue +list --state open --format json
5Claude Code 分析 Issue 列表按类型分类bug/feature/question
6Claude Code 调用 gitlink-cli issue +label-add 添加分类标签
7Claude Code 调用 gitlink-cli pr +list 获取近期 PR
8Claude Code 生成周报摘要,包含 Issue 统计、PR 合并情况、贡献者活跃度
Skill 调用链:
gitlink-shared认证检查→ gitlink-issueIssue 操作)→ gitlink-prPR 操作)→ gitlink-workflow工作流编排
5、系统部署模型
本项目采用单二进制分发模式,部署简单。
1构建环境
- Go 1.26.1+
- Makefile 管理构建流程
- 构建命令make build通过 -ldflags 注入版本号)
2分发方式
- 直接下载:从 GitHub Releases 下载预编译二进制
- npm 安装npm install -g gitlink-cli
- 源码编译go install github.com/gitlink-org/gitlink-cli@latest
- 安装脚本install.shLinux/macOS、install.ps1Windows
3目标平台
- Windowsamd64
- macOSamd64、arm64
- Linuxamd64、arm64
4依赖服务
- GitLink APIhttps://www.gitlink.org.cn/api主站 API
- GitLink Gatewayhttps://gateway.gitlink.org.cn/apiWiki API 专用)
- OS Keychain系统密钥管理服务存储 Token
5CI/CD 集成:
- GitHub Actions.github/workflows/release.yml 自动构建和发布
- GitLink DevOps.devops/gitlink-cli-autodeploy.yml 平台自动部署
6部署架构
用户终端
gitlink-cli本地二进制
↓ HTTP/HTTPS
GitLink API Servergitlink.org.cn
GitLink Gatewaygateway.gitlink.org.cnWiki 专用)
gitlink-cli 是纯客户端工具,不运行后台服务。所有数据存储在用户本地(配置文件 + OS Keychain业务数据完全来自 GitLink 平台 API 的实时查询。

BIN
gitlink-cli.exe Normal file

Binary file not shown.

BIN
npm/bin/gitlink-cli.exe Normal file

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"WebFetch(domain:apifox.com)",
"WebSearch",
"WebFetch(domain:gitlink.org.cn)",
"WebFetch(domain:www.gitlink.org.cn)",
"Bash(sed 's/^//')",
"Bash(sed 's/ *$//')",
"Bash(grep -h \"CallAPI\\\\|\\\\.Do\\(\" C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*.go C:/Users/Lenovo/Desktop/soft运维/gitlink-cli/shortcuts/*/*/*.go)",
"Bash(go build:*)",
"Bash(./gitlink-cli.exe file:*)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(./gitlink-cli.exe milestone:*)",
"Bash(./gitlink-cli.exe pr:*)",
"Bash(go vet:*)",
"Bash(go test:*)",
"Bash(cd:*)"
]
}
}

BIN
shortcuts/gitlink-cli.exe Normal file

Binary file not shown.

View File

@ -482,7 +482,12 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
}
// 获取标签参数并转换为数字 ID
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
tags, err := resolveIssueTags(ctx)
if err != nil {
return fmt.Errorf("cannot resolve issue tags: %w", err)
}
tagID, err := parseLabel(label, tags)
if err != nil {
return err
}
@ -514,7 +519,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error {
continue
}
// 调用 updateIssueField 修改标签issue_tag_ids 是数组)
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}}); err != nil {
if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++

View File

@ -12,20 +12,21 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
var issueTagCache sync.Map
var issueTagCache sync.Map //全局缓存变量
// 获取项目标签映射
// resolveIssueTags fetches the project's issue tags and returns a name→id mapping.
// Results are cached per owner/repo.
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
if cached, ok := issueTagCache.Load(key); ok { //先从缓存查
return cached.(map[string]int), nil // 命中缓存则直接返回
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) //api路径
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q)
env, err := ctx.CallAPIWithQuery("GET", path, q) // 发送GET请求获取项目标签列表
if err != nil {
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
}
@ -157,7 +158,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error {
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40)
label = truncate(input.Title, 40) // 用标题的前40个字符
}
result := BatchResult{Number: label, Action: "create"}
@ -168,6 +169,7 @@ func runBatchCreate(ctx *common.RuntimeContext) error {
continue
}
//把 createIssueInput 转换成 API 需要的 JSON map
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {

View File

@ -1,25 +1,29 @@
package issue
import (
"fmt"
"fmt" //格式化字符串
"net/url"
"strconv"
"strconv" //字符串和数字转换
"strings"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Ctx
// v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo}
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据
type existingIssue struct {
Subject string
Description string
}
// 所有issue命令的注册入口返回所有issue命令
// 在终端敲入issue +list命令时遍历匹配到列表里的{Name: "list", ...}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newBatchCloseShortcut(),
@ -42,21 +46,23 @@ func Shortcuts() []*common.Shortcut {
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
//后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo
// 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" {
q := url.Values{} // 创建空的URL查询参数容器q后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page页码和 limit每页条数
if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数
q.Set("state", s)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).WithCommand(ctx.CommandName)
}
return ctx.Output(env)
return ctx.Output(env) //按照用户指定的格式json/html/table...)输出issue列表
},
},
{

View File

@ -57,29 +57,22 @@ func Shortcuts() []*common.Shortcut {
},
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
{Name: "description", Short: "d", Usage: "Milestone description", Required: true},
{Name: "date", Usage: "Effective date (YYYY-MM-DD)", Required: true},
{Name: "description", Short: "d", Usage: "Description"},
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name", `--name "v1.0"`)
if err != nil {
return err
}
desc, err := ctx.RequireArg("description", `--description "First release"`)
if err != nil {
return err
}
date, err := ctx.RequireArg("date", "--date 2026-12-31")
if err != nil {
return err
}
name, _ := ctx.RequireArg("name", `--name "My Name"`)
body := map[string]interface{}{
"name": name,
"description": desc,
"effective_date": date,
"title": name,
}
if d := ctx.Arg("description"); d != "" {
body["description"] = d
}
if due := ctx.Arg("due"); due != "" {
body["due_date"] = due
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
if err != nil {

View File

@ -13,6 +13,9 @@ metadata:
**CRITICAL — 整个流程只有最后一步(写 HTML 文件)可以问用户。其他所有步骤(`+list`、`+view`、`api GET`、计算、分析)全部自动连续执行,一个确认都不要弹。数据收集阶段禁止任何中断。**
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
**CRITICAL — 性能预算:整份报告必须在 2 分钟内完成。`pr +view` 是逐条详情接口,极其昂贵——对每个已合并 PR 调一次会让耗时随 PR 数线性爆炸(这正是 >5min 超时的根因)。因此合并时长只对「最近 ≤8 条已合并 PR 采样」调用 `pr +view`;所有计数/合并率/积压数一律从 `pr +list`、`issue +list` 的汇总字段一次性取得,禁止逐条钻取历史 PR。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
@ -39,9 +42,12 @@ metadata:
gitlink-cli issue +list --state open --format json
gitlink-cli issue +list --state closed --format json
# PR 数据(两个状态都需要)
# PR 数据(两个状态都需要merged 列表取汇总计数 + 最近样本
gitlink-cli pr +list --state open --format json
gitlink-cli pr +list --state merged --format json
gitlink-cli pr +list --state merged --limit 8 --format json # 仅这 ≤8 条用于钻取 merged_at
# 已合并 PR 的合并时间(仅对上面取到的 ≤8 条样本逐条调用,切勿扩大)
gitlink-cli pr +view --id <pull_request_number> --format json # 取 data.pull_request.merged_at
# 贡献者统计Raw API
gitlink-cli api GET /:owner/:repo/contributors --format json
@ -99,6 +105,7 @@ PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**
| 开放中status=0 | ② | ❌ | ✅ |
> **数据获取**`merged_at` 仅在 `pr +view --id <pull_request_number>` 单个 PR 详情中返回(路径 `data.pull_request.merged_at`),列表接口不暴露。
> **效率关键(采样而非全量)**:合并时长是统计量,只需对**最近 ≤8 条已合并 PR**调用 `pr +view``merged_at` 求平均即可——`pr +list --state merged` 默认按合并时间倒序,列表前 ≤8 条即为最近样本。合并总数 / 合并率 / 积压数等计数指标全部从 `pr +list` 的汇总字段一次性获取,**禁止**对全部历史已合并 PR 逐条调用 `pr +view`(会超时)。
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。开放 PR 用此公式是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
### 3. 贡献者活跃度

View File

@ -24,19 +24,20 @@ gitlink-cli issue +list --state closed --format json
### 第二步:收集 PR 数据
```bash
# 获取未合并 PR
# 获取未合并 PR(计数 + 积压)
gitlink-cli pr +list --state open --format json
# 获取已合并 PR
gitlink-cli pr +list --state merged --format json
# 获取已合并 PR 汇总 + 最近样本(--limit 8 限定钻取范围,避免超时)
gitlink-cli pr +list --state merged --limit 8 --format json
# 仅对上面取到的 ≤8 条样本逐条取 merged_at切勿对全部历史 PR 调用)
gitlink-cli pr +view --id <pull_request_number> --format json
```
# AI 从返回中提取:
# - merged count: 10
# - open count: 0
# - 合并率: 10/11 = 91%
# - 最新合并 PR: #11 (2026-06-04)
# - PR 平均合并时间需逐条计算
```
# - merged count / open count从 pr +list 汇总字段
# - 合并率: merged / (merged + closed)
# - 已合并 PR 平均合并时长:仅基于这 ≤8 条样本的 merged_at - created_at 求平均
### 第三步:收集贡献者统计
@ -90,7 +91,7 @@ AI 读取 `skills/gitlink-health/template.html`,将计算后的指标替换所
## AI Agent 执行要点
1. **数据收集顺序**:先收集 Issue 和 PRShortcut 命令),再收集 contributorsRaw API避免一次性大量 API 调用
2. **分页处理**Issue 和 PR 数量超过单页限制时,用 `--page` 逐页获取
2. **效率优先于全量**:报告是趋势快照,不是审计。计数/合并率从 `+list` 汇总字段一次取得;合并时长只采样最近 ≤8 条已合并 PR`pr +view`)。**禁止**分页遍历全部历史 Issue/PR也禁止对每个已合并 PR 都调 `pr +view`——这是 >5min 超时的根因
3. **时间计算**ISO 8601 格式解析优先Unix 时间戳更可靠但 PR 的 `pr_created_unix` 仅部分返回
4. **指标计算容错**:样本不足时标注而非报错,新项目可能仅有少量数据
5. **评分可按需调整**:新项目无 Release 时,"无近期发布"项自动跳过

View File

@ -43,14 +43,16 @@ PR 返回的关键字段:
| 字段 | 用途 |
|------|------|
| `pull_request_number` | PR 编号 |
| `pull_request_number` | PR 编号(用于 `pr +view --id` 钻取) |
| `name` (title) | PR 标题 |
| `pull_request_status` | 状态0=open, 1=merged, 2=closed |
| `author_login` | 作者 |
| `pr_full_time` | 创建时间 (ISO 8601) |
| `pr_merged_at` | 合并时间 |
| `pr_created_unix` | 创建时间 (Unix timestamp) |
| `merged_issues_size` / `open_count` / `close_count` | 合并/开放/关闭未合并计数(汇总字段,与 `--limit` 无关,一次性取得) |
> ⚠️ `pr +list` **不返回** `merged_at`(合并时间)字段,只有创建时间。`merged_at` 仅在 `pr +view --id <pull_request_number>``data.pull_request.merged_at` 中暴露。
> ⚠️ **为控制耗时,只对最近 ≤8 条已合并 PR 调用 `pr +view` 取 `merged_at`**(列表默认按合并时间倒序,前 ≤8 条即最近样本)。`pr +list --state merged --limit 8` 一条命令即可同时拿到汇总计数(`merged_issues_size` 等不受 limit 影响)和 8 条样本。禁止遍历全部历史已合并 PR。
> ⚠️ `--state` 参数仅影响 `merged_count`/`open_count`/`closed_count` 汇总计数API 返回的 issues 列表可能包含所有状态的 PR。需按 `pull_request_status` 客户端过滤。
### 步骤 3收集贡献者统计
@ -100,17 +102,17 @@ gitlink-cli api GET /:owner/:repo/activity --format json
数据收集覆盖以下时间范围:
- **Issue**: 所有未关闭 + 近期已关闭(默认取最近 100 条
- **PR**: 所有未合并 + 近期已合并(默认取最近 100 条
- **Issue**: 所有未关闭 + 近期已关闭(计数从汇总字段取;时间计算用近期限定条数即可
- **PR**: 所有未合并 + 近期已合并(计数/合并率从 `pr +list` 汇总字段取;**合并时长仅采样最近 ≤8 条已合并 PR** 钻取 `merged_at`
- **Contributors**: 项目全量历史数据
- **Activity**: 最近 30 天
对于大型项目,可通过 `--page` 分页获取更多数据
大型项目也**不要**通过 `--page` 分页遍历全部历史数据——健康度是趋势快照,计数取汇总字段、合并时长取最近 ≤8 条样本即可,避免超时
## 注意事项
- 计算响应时间需要 Issue 有 `closed_at` 字段,仅已关闭 Issue 才会返回此字段
- PR 合并时间需通过 `pr_full_time` 与当前时间对比估算,或结合 PM `/weekly_issues` API
- PR 合并时间**不能**用「当前时间 - `pr_full_time`」估算(已合并 PR 必须取真实 `merged_at``merged_at` 通过 `pr +view --id` 获取,**仅采样最近 ≤8 条已合并 PR**
- `contributors` 端点统计基于默认分支(通常为 `master`),不含其他分支
- `--owner` / `--repo` 在仓库目录下可自动解析
- 时间计算使用 Unix 时间戳(`pr_created_unix`)比解析字符串格式(`pr_full_time`)更可靠

View File

@ -73,9 +73,9 @@ PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**
### 计算公式
```
# ① 已合并 PR 平均合并时长
# ① 已合并 PR 平均合并时长(基于最近 ≤8 条样本)
time_merged_i = merged_at_i - created_at_i
avg_merged = sum(time_merged_i) / merged_count
avg_merged = sum(time_merged_i) / sample_count # sample_count = 实际采样的 PR 条数≤8不是历史合并总数
# ② 开放 PR 平均等待时长
time_open_i = now - created_at_i
@ -123,28 +123,16 @@ merge_rate = merged_count / (merged_count + closed_count) × 100%
### 数据可用性
| 字段 | 来源命令 | 路径 |
|------|---------|------|
| `created_at` | `pr +list` | `data.issues[].pr_full_time``pr_created_unix` |
| `merged_at` | `pr +view --id <pull_request_number>` | `data.pull_request.merged_at` |
> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。已合并 PR 必须用真实的 `merged_at`
> 开放 PR 用「当前时间 - 创建时间」是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。
### 数据可用性
PR 合并时间**只与 PR 自身时间相关**(创建时间 + 合并时间),**与当前时间无关**。
实际数据源:
| 字段 | 来源命令 | 路径 |
|------|---------|------|
| `created_at` | `pr +list --state merged` | `data.issues[].pr_full_time``pr_created_unix` |
| `merged_at` | `pr +view --id <pull_request_id>` | `data.pull_request.merged_at` |
| 合并总数 / 合并率 / 积压数 | `pr +list` 汇总字段 | `data.close_count` 等(一次性获取,勿逐条钻取) |
| `merged_at` | `pr +view --id <pull_request_number>` | `data.pull_request.merged_at` |
**注意**`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。需要对每个已合并 PR 调用一次详情接口。
> **绝对禁止**:使用「当前时间 - 创建时间」作为合并时间估算。这种做法会把开放中或近期合并的 PR 误判为"超长合并时间",与 PR 合并效率的真实含义相悖。
> **效率关键(采样而非全量)**`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。但合并时长是统计量——**只对最近 ≤8 条已合并 PR 采样调用 `pr +view`** 即可求得稳定平均,无需遍历全部历史 PR。`pr +list --state merged` 默认按合并时间倒序,取列表前 ≤8 条作为样本。合并总数/合并率从 `pr +list` 汇总字段一次性取得。
> **绝对禁止**:使用「当前时间 - 创建时间」作为已合并 PR 的合并时间估算(会把开放中或近期合并的 PR 误判为"超长合并时间")。开放 PR 用「当前时间 - 创建时间」是允许且必要的,等待时长反映积压。
## 指标 3贡献者活跃度

Binary file not shown.

View File

@ -0,0 +1,84 @@
{
"permissions": {
"allow": [
"Bash(git push *)",
"Bash(gitlink-cli version:*)",
"Bash(winget install:*)",
"Bash(scoop install:*)",
"Bash(export PATH=\"$PATH:/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links\")",
"Bash(jq --version)",
"Bash(bash test.sh zzx-coder gitlink-cli)",
"Bash(gitlink-cli auth:*)",
"Bash(bash -x test.sh zzx-coder gitlink-cli)",
"Bash(source lib/common.sh)",
"Bash(check_auth)",
"Bash(bash -n test.sh)",
"Bash(gitlink-cli issue:*)",
"Bash(gitlink-cli pr:*)",
"Bash(where gitlink-cli:*)",
"Bash(export PATH=\"/c/Users/Lenovo/AppData/Roaming/npm:/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links:$PATH\")",
"Bash(echo \"Exit code: $?\")",
"Bash(bash 01-community-ops.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 03-project-init.sh --owner zzx-coder --name test-workflow --description \"测试工作流\" --lang go --dry-run)",
"Bash(gitlink-cli wiki:*)",
"Bash(gitlink-cli repo:*)",
"Bash(bash 04-multi-repo-collab.sh --org zzx-coder --dry-run)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --dry-run)",
"Bash(bash 04-multi-repo-collab.sh --org zzx-coder --repos \"gitlink-cli\" --dry-run)",
"Bash(echo \"Exit: $?\")",
"Bash(gitlink-cli contrib:*)",
"Bash(gitlink-cli api:*)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 5 --dry-run)",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 3 --dry-run)",
"Bash(jq .data.issues[0])",
"Read(//c/Users/Lenovo/**)",
"Bash(find /d -name \"jq.exe\")",
"Bash(find /mingw64 -name \"jq*\")",
"Bash(\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" --version 2>&1)",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli pr +list --owner zzx-coder --repo gitlink-cli --state merged --limit 1 --format json)",
"Bash(\"$JQ\" '.data.issues[0] | {author_login, pull_request_id, id, project_issues_index, name}')",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli pr +files --owner zzx-coder --repo gitlink-cli --id 15940 --format json)",
"Bash($JQ '{total_addition, total_deletion}')",
"Bash(\"$JQ\" '.')",
"Bash(\"$JQ\" '{total_addition, total_deletion, file_count: \\(.data.files | length\\), sum_add: [.data.files[].addition] | add, sum_del: [.data.files[].deletion] | add}')",
"Bash(bash 05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --sample 3)",
"Bash(source lib/common.sh OWNER=\"zzx-coder\" REPO=\"gitlink-cli\" WIKI_CONTENT=\"# Test Wiki Content:*)",
"Bash(source lib/common.sh OWNER=\"zzx-coder\" REPO=\"gitlink-cli\" WIKI_CONTENT=\"# Test with backticks:*)",
"Bash(echo 'jq failed, raw output:' gl_run wiki +create --owner $OWNER --repo $REPO --title 'Test Backticks' --content $WIKI_CONTENT)",
"Bash(JQ=\"/c/Users/Lenovo/AppData/Local/Microsoft/WinGet/Links/jq.exe\" gitlink-cli wiki +list --owner zzx-coder --repo gitlink-cli --format json)",
"Bash($JQ \".data.wikis[] | .title\")",
"Bash(bash test.sh 2>&1)",
"Bash(ls:*)",
"Bash(bash 03-project-init.sh --owner zzx-coder --name test-repo --description \"Test repo\" --dry-run)",
"Bash(where bash:*)",
"Bash(where git:*)",
"Bash(for cmd:*)",
"Bash(do echo:*)",
"Bash(./gitlink-cli.exe $cmd --help)",
"Bash(done)",
"Bash(./gitlink-cli.exe --help)",
"Bash(./gitlink-cli.exe issue:*)",
"Bash(powershell -ExecutionPolicy Bypass -File \"01-community-ops.ps1\" -Help)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { Import-Module ./lib/common.psm1 -Force; Check-Auth }\")",
"Bash(powershell -Command \"& { $output = & gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limit 100 --format json; Write-Host ''''TYPE='''' $output.GetType\\(\\).Name; Write-Host ''''COUNT='''' $output.Count; Write-Host ''''FIRST='''' $output[0] }\")",
"Bash(powershell -ExecutionPolicy Bypass -File ./_debug.ps1)",
"Bash(cd:*)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./01-community-ops.ps1 -DryRun }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./03-project-init.ps1 -DryRun -Owner zzx-coder -Repo gitlink-cli }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./04-multi-repo-collab.ps1 -DryRun }\")",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./05-contributor-growth.ps1 -DryRun }\")",
"Bash(gitlink-cli release:*)",
"Bash(powershell -ExecutionPolicy Bypass -Command \"& { $ErrorActionPreference=''''Continue''''; . ./04-multi-repo-collab.ps1 -DryRun -Org zzx-coder }\")",
"Bash(xxd)",
"Bash(powershell -Command \"\\\\$b = '{\\\\\\\\\\\\\"assigned_to_id\\\\\\\\\\\\\": \\\\\\\\\\\\\"zzx-coder\\\\\\\\\\\\\"}'; Write-Host \\\\$b; gitlink-cli api PATCH '/v1/zzx-coder/gitlink-cli/issues/145142' --body \\\\$b 2>&1 | Select-Object -First 5\")",
"Bash(ln:*)",
"Bash(grep -n \"func v1RepoPath\" shortcuts/issue/*.go)",
"Bash(go build:*)",
"Bash(python -c \":*)",
"Bash(./gitlink-cli.exe pr:*)",
"Bash(/dev/null done:*)",
"Bash(bash academic/06-research-insights.sh --owner zzx-coder --repo gitlink-cli)"
]
}
}

View File

@ -3,7 +3,6 @@
# Flow: 收集周期数据 → 10类关键词分类 → 生成 Release Notes + 社区周报
# 遵循 gitlink-changelog skill 的收集→分类→发布流程
# 条目格式: - 描述 (#编号) (@作者) [分类]
=======
# ----------------------------------------------------------------
#Requires -Version 5.1
@ -18,11 +17,10 @@ param(
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 01-community-ops.ps1 [-Owner O] [-Repo R] [-PeriodHours N] [-ReleaseVersion TAG] [-DryRun]"
=======
exit 0
}
@ -34,7 +32,6 @@ $Owner = $r.Owner; $Repo = $r.Repo
# Phase 1: 时间窗口和基线
# ================================================================
Log-Title "Phase 1: Time Window"
=======
$periodStart = (Get-Date).AddHours(-$PeriodHours)
@ -52,7 +49,6 @@ if ($releasesJson) {
$releases = if ($relData.releases) { @($relData.releases) } elseif ($relData -is [array]) { @($relData) } else { @() }
if ($releases.Count -gt 0) {
$prevTag = if ($releases[0].tag_name) { $releases[0].tag_name } else { "" }
=======
}
} catch {}
@ -95,7 +91,6 @@ if ($prevTag -ne "initial") {
}
Log-Ok "Commits: $commitCount"
<<<<<<< HEAD
# -- Merged PRs --
Log-Step "Collecting merged PRs..."
$prItems = @()
@ -106,7 +101,7 @@ if ($prsJson) {
$prData = ($prsJson | ConvertFrom-Json).data
$prs = if ($prData.issues) { @($prData.issues) } elseif ($prData.pulls) { @($prData.pulls) } elseif ($prData -is [array]) { @($prData) } else { @() }
foreach ($pr in $prs) {
$prTitle = if ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "N/A" }
$prTitle = if ($pr.name) { $pr.name } elseif ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "N/A" }
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } else { "?" }
$prAuthor = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { "?" }
$prItems += "- $prTitle (#$prNum) (@$prAuthor)"
@ -127,6 +122,9 @@ $docLines = @()
$otherLines = @()
$issContributors = @{}
$issCount = 0
# Dedupe by issue index: the same issue can be returned by both the open
# and closed state queries, which would otherwise double-count it.
$seenIssues = @{}
$today = (Get-Date -Format "yyyy-MM-dd")
$yesterday = ((Get-Date).AddDays(-1)).ToString("yyyy-MM-dd")
@ -138,6 +136,8 @@ foreach ($state in @("open","closed")) {
$issues = if ($issData.issues) { @($issData.issues) } elseif ($issData -is [array]) { @($issData) } else { @() }
foreach ($iss in $issues) {
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
if ($num -ne "?" -and $seenIssues.ContainsKey($num)) { continue }
if ($num -ne "?") { $seenIssues[$num] = $true }
$title = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "N/A" }
$desc = if ($iss.description) { $iss.description } else { "" }
$author = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "?" }
@ -151,7 +151,6 @@ foreach ($state in @("open","closed")) {
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod) { continue }
<<<<<<< HEAD
$line = "- $title (#$num) (@$author)"
if ($stateName -match "关闭|closed") { $line += " [已关闭]" }
@ -191,140 +190,199 @@ $featSection = if ($featLines.Count -gt 0) { ($featLines -join "`n") } else { "_
$docSection = if ($docLines.Count -gt 0) { ($docLines -join "`n") } else { "_无_" }
$otherSection = if ($otherLines.Count -gt 0) { ($otherLines -join "`n") } else { "_无_" }
Log-Ok "Issues in period: $issCount"
=======
$closedJson = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
$closedCount = if ($closedJson) { @($closedJson.data.issues).Count } else { 0 }
$mergedJson = Invoke-GL "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
$mergedData = @()
$mergedCount = 0
if ($mergedJson) {
$d = $mergedJson.data
if ($d.issues) { $mergedData = @($d.issues) }
elseif ($d.pulls) { $mergedData = @($d.pulls) }
elseif ($d -is [array]) { $mergedData = $d }
$mergedCount = $mergedData.Count
# -- 动态收集标签 ID 映射 (与 01a-issue-triage.ps1 一致) --
Log-Step "Discovering label IDs..."
$tagIdMap = @{}
# 优先从项目 issue_tags API 获取
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
if ($tagsApi) {
try {
$tData = ($tagsApi | ConvertFrom-Json).data
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
foreach ($t in $tagList) {
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
}
} catch {}
}
# 补充:从已有 issue 的 tags 字段收集
foreach ($state in @("open","closed")) {
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
if ($sample) {
try {
$sData = ($sample | ConvertFrom-Json).data
$sIssues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() }
foreach ($iss in $sIssues) {
if ($iss.tags) {
foreach ($t in $iss.tags) {
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
}
}
}
} catch {}
}
}
$tagFound = @()
foreach ($k in $tagIdMap.Keys) { $tagFound += "$k($($tagIdMap[$k]))" }
if ($tagFound.Count -gt 0) { Log-Ok "Found tags: $($tagFound -join ' ')" } else { Log-Warn "No tags found in repo" }
# -- 打标签 & 分配责任人 (自动分类后的实际写入) --
# 收集需要处理的 Issue 信息 (编号, issue ID, 标签名, 作者)
$triageItems = @()
$seenTriage = @{}
foreach ($state in @("open","closed")) {
$issJson2 = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100
if (-not $issJson2) { continue }
try {
$issData2 = ($issJson2 | ConvertFrom-Json).data
$issues2 = if ($issData2.issues) { @($issData2.issues) } elseif ($issData2 -is [array]) { @($issData2) } else { @() }
foreach ($iss in $issues2) {
$num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" }
if ($num -eq "?" -or $seenTriage.ContainsKey($num)) { continue }
$created = if ($iss.created_at) { $iss.created_at } else { "" }
$closed = if ($iss.closed_at) { $iss.closed_at } else { "" }
$inPeriod = $false
if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} }
if (-not $inPeriod) { continue }
$seenTriage[$num] = $true
$title2 = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "" }
$desc2 = if ($iss.description) { $iss.description } else { "" }
$author2 = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "" }
$issueId2 = if ($iss.id) { $iss.id } else { $num }
# 检查是否已有标签
$hasLabel = $false
if ($iss.tags -and @($iss.tags).Count -gt 0) { $hasLabel = $true }
# 关键词分类
$combined2 = "$title2 $desc2".ToLower()
$chosenLabel = ""
if ($combined2 -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') {
$chosenLabel = "缺陷"
} elseif ($combined2 -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') {
$chosenLabel = "功能"
} elseif ($combined2 -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') {
$chosenLabel = "文档"
} elseif ($combined2 -match '(?i)test|测试|用例|覆盖|验证') {
$chosenLabel = "测试"
} elseif ($combined2 -match '(?i)duplicate|重复|重复的') {
$chosenLabel = "重复"
} elseif ($combined2 -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') {
$chosenLabel = "疑问"
} elseif ($combined2 -match '(?i)help|协助|帮助|协作|请求帮助|互助') {
$chosenLabel = "协助"
} elseif ($combined2 -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') {
$chosenLabel = "搁置"
} elseif ($combined2 -match '(?i)task|todo|任务|待办|计划|安排') {
$chosenLabel = "任务"
} elseif ($combined2 -match '(?i)support|兼容|环境|依赖|平台|适配') {
$chosenLabel = "支持"
}
# 检查是否已有负责人
$hasAssignee = $false
if ($iss.assigned_to_id -and "$($iss.assigned_to_id)" -ne "0" -and "$($iss.assigned_to_id)" -ne "") { $hasAssignee = $true }
if ($iss.assigned_to -and $iss.assigned_to.login) { $hasAssignee = $true }
if ($chosenLabel -or -not $hasAssignee) {
$triageItems += @{ num=$num; id=$issueId2; title=$title2; desc=$desc2; author=$author2; label=$chosenLabel; hasLabel=$hasLabel; hasAssignee=$hasAssignee }
}
}
} catch {}
}
$labelOk = 0; $labelFail = 0; $labelSkip = 0
$assignOk = 0; $assignFail = 0; $assignSkip = 0
if ($triageItems.Count -gt 0) {
Log-Step "Applying labels and assignees to $($triageItems.Count) issue(s)..."
foreach ($item in $triageItems) {
# -- 打标签 --
if (-not $item.label) {
$labelSkip++
} elseif ($item.hasLabel) {
Log-Info " #$($item.num) already has label, skipping"
$labelSkip++
} elseif ($DryRun) {
Log-Warn " [DRY RUN] Would tag #$($item.num) with '$($item.label)'"
$labelSkip++
} else {
$tgtId = $tagIdMap[$item.label]
if (-not $tgtId) {
Log-Warn " #$($item.num): label '$($item.label)' not found in repo tags — create on website first"
$labelFail++
} else {
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
if ($tagResult) {
try {
$tagOkFlag = (($tagResult | ConvertFrom-Json).ok -eq $true)
} catch { $tagOkFlag = $false }
if ($tagOkFlag) { Log-Ok " #$($item.num) tagged: $($item.label)"; $labelOk++ } else { Log-Warn " #$($item.num) tag failed"; $labelFail++ }
} else { Log-Warn " #$($item.num) tag failed (no response)"; $labelFail++ }
}
}
# -- 分配责任人 (默认分配给 Issue 作者) --
if ($item.hasAssignee) {
$assignSkip++
} elseif (-not $item.author -or $item.author -eq "?") {
Log-Info " #$($item.num): no author info, skipping assign"
$assignSkip++
} elseif ($DryRun) {
Log-Warn " [DRY RUN] Would assign #$($item.num) to @$($item.author)"
$assignSkip++
} else {
# Resolve login name to numeric user ID
$authorID = $null
$isNum = $false
try { [void][int]$item.author; $isNum = $true } catch {}
if ($isNum) {
$authorID = $item.author
} else {
$userJson = Invoke-GL api,GET,"/users/$($item.author)"
if ($userJson) {
try {
$userData = ($userJson | ConvertFrom-Json).data
if ($userData.id) { $authorID = [int]$userData.id }
elseif ($userData.user_id) { $authorID = [int]$userData.user_id }
} catch {}
}
}
if (-not $authorID) {
Log-Warn " #$($item.num): cannot resolve user ID for '@$($item.author)'"
$assignFail++
} else {
$bodyJson = "{`"assigner_ids`":[$authorID]}"
$assignResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson
if ($assignResult) {
try {
$assignOkFlag = (($assignResult | ConvertFrom-Json).ok -eq $true)
} catch { $assignOkFlag = $false }
if ($assignOkFlag) { Log-Ok " #$($item.num) assigned to @$($item.author) (ID:$authorID)"; $assignOk++ } else { Log-Warn " #$($item.num) assign failed"; $assignFail++ }
} else { Log-Warn " #$($item.num) assign failed (no response)"; $assignFail++ }
}
}
}
Log-Ok "Labels: $labelOk applied, $labelFail failed, $labelSkip skipped"
Log-Ok "Assign: $assignOk applied, $assignFail failed, $assignSkip skipped"
} else {
Log-Info "No new issues require triage in this period"
}
>>>>>>> master
# -- 贡献者汇总 --
$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object
$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" }
<<<<<<< HEAD
# ================================================================
# Phase 3: 生成 Release Notes (changelog skill 模板)
# ================================================================
Log-Title "Phase 3: Generate Release Notes"
=======
$reportTitle = "Community Weekly Report: $weekStart ~ $weekEnd"
$reportBody = "# $reportTitle" + "`n`n"
$reportBody += "## 概览" + "`n"
$reportBody += "- 仓库: **$Owner/$Repo**" + "`n"
$reportBody += "- 当前开放 Issue: **$newIssuesCount**" + "`n"
$reportBody += "- 已关闭 Issue: **$closedCount**" + "`n"
$reportBody += "- 已合并 PR: **$mergedCount**" + "`n`n"
# 分类汇总
$reportBody += "## Issue 分类汇总" + "`n"
$reportBody += "| 类型 | 数量 |" + "`n"
$reportBody += "|------|------|" + "`n"
$reportBody += "| Bug | $($BugIds.Count) |" + "`n"
$reportBody += "| Feature | $($FeatureIds.Count) |" + "`n"
$reportBody += "| Question | $($QuestionIds.Count) |" + "`n"
$reportBody += "| Docs | $($DocsIds.Count) |" + "`n`n"
# 开放 Issue 清单(含分类标记)
$reportBody += "## 当前开放 Issue 清单" + "`n"
$reportBody += "| # | 标题 | 分类 | 创建时间 |" + "`n"
$reportBody += "|---|------|------|----------|" + "`n"
foreach ($issue in $issues) {
$id = $issue.id
$title = if ($issue.subject) { $issue.subject } elseif ($issue.title) { $issue.title } else { "-" }
$title = ($title -replace '\|', '\\|')
$cat = if ($BugIds -contains $id) { "Bug" }
elseif ($FeatureIds -contains $id) { "Feature" }
elseif ($QuestionIds -contains $id) { "Question" }
elseif ($DocsIds -contains $id) { "Docs" }
else { "-" }
$created = if ($issue.created_at) { $issue.created_at } else { "-" }
$reportBody += "| #$id | $title | $cat | $created |" + "`n"
}
$reportBody += "`n"
# 已关闭 Issue 清单
$reportBody += "## 近期已关闭 Issue" + "`n"
if ($closedCount -gt 0) {
$closedIssues = @($closedJson.data.issues)
$closedLimit = [Math]::Min($closedCount, 15)
$reportBody += "| # | 标题 |" + "`n"
$reportBody += "|---|------|" + "`n"
for ($i = 0; $i -lt $closedLimit; $i++) {
$it = $closedIssues[$i]
$cid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "-" }
$ctitle = if ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "-" }
$ctitle = ($ctitle -replace '\|', '\\|')
$reportBody += "| #$cid | $ctitle |" + "`n"
}
if ($closedCount -gt $closedLimit) {
$reportBody += "| ... | 还有 $($closedCount - $closedLimit) 条 |`n"
}
} else {
$reportBody += "_本周无关闭记录_" + "`n"
}
$reportBody += "`n"
# 已合并 PR 清单(含作者)
$reportBody += "## 近期已合并 PR" + "`n"
if ($mergedCount -gt 0) {
$prLimit = [Math]::Min($mergedCount, 15)
$reportBody += "| # | 标题 | 作者 |" + "`n"
$reportBody += "|---|------|------|" + "`n"
for ($i = 0; $i -lt $prLimit; $i++) {
$pr = $mergedData[$i]
$prId = if ($pr.id) { $pr.id } elseif ($pr.number) { $pr.number } else { "-" }
$ptitle = if ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "-" }
$ptitle = ($ptitle -replace '\|', '\\|')
$pauthor = if ($pr.author -and $pr.author.login) { $pr.author.login } elseif ($pr.user -and $pr.user.login) { $pr.user.login } else { "-" }
$reportBody += "| #$prId | $ptitle | @$pauthor |" + "`n"
}
if ($mergedCount -gt $prLimit) {
$reportBody += "| ... | 还有 $($mergedCount - $prLimit) 条合并 PR |`n"
}
} else {
$reportBody += "_本周无合并记录_" + "`n"
}
$reportBody += "`n"
# 贡献者排行(按合并 PR 数)
$reportBody += "## 贡献者排行(按合并 PR 数)" + "`n"
if ($mergedCount -gt 0) {
$contributorMap = @{}
foreach ($pr in $mergedData) {
$login = if ($pr.author -and $pr.author.login) { $pr.author.login } elseif ($pr.user -and $pr.user.login) { $pr.user.login } else { $null }
if ($login) {
if ($contributorMap.ContainsKey($login)) { $contributorMap[$login]++ }
else { $contributorMap[$login] = 1 }
}
}
$reportBody += "| 排名 | 贡献者 | 合并 PR 数 |" + "`n"
$reportBody += "|------|--------|------------|" + "`n"
$rank = 1
foreach ($kv in ($contributorMap.GetEnumerator() | Sort-Object Value -Descending)) {
$reportBody += "| $rank | @$($kv.Name) | $($kv.Value) |" + "`n"
$rank++
}
} else {
$reportBody += "_本周无合并记录_" + "`n"
}
$reportBody += "`n"
$reportBody += "## 本周自动化执行" + "`n"
$reportBody += "- 自动分类并打标 Issue: **$totalClassified** 条" + "`n"
$reportBody += "- 已为 Bug/Feature 类 Issue 指派负责人" + "`n`n"
$reportBody += "---" + "`n"
$reportBody += "*Auto-generated by gitlink-cli community-ops workflow*"
>>>>>>> master
$releaseBody = @"
# 🎉 Release $newVersion
@ -364,7 +422,6 @@ $contribText
Write-Host $releaseBody
Write-Host ""
<<<<<<< HEAD
# ================================================================
# Phase 4: 发布 Release
# ================================================================
@ -380,30 +437,6 @@ if ($DryRun) {
Log-Info "View: https://www.gitlink.org.cn/$Owner/$Repo/releases"
} else {
Log-Warn "Release may have failed (tag might exist)"
=======
Log-Step "Publishing weekly report to Wiki..."
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $reportTitle, "--content", $reportBody
if ($wikiResult -and $wikiResult.ok) { Log-Ok "Weekly report published to Wiki" } else { Log-Warn "Wiki publish may have failed" }
# ----------------------------------------------------------------
Log-Title "Phase 4: Auto-Publish Release Notes"
# ----------------------------------------------------------------
Log-Step "Collecting recent changes for release notes..."
$tagName = "weekly-$(Get-Date -Format 'yyyyMMdd')"
$releaseName = "Weekly Release $(Get-Date -Format 'yyyy-MM-dd')"
$releaseBody = "# Release Notes - $(Get-Date -Format 'yyyy-MM-dd')" + "`n`n"
$releaseBody += "## Merged PRs ($mergedCount)"
if ($mergedCount -gt 0) {
$limit = [Math]::Min($mergedCount, 10)
for ($i = 0; $i -lt $limit; $i++) {
$prTitle = if ($mergedData[$i].subject) { $mergedData[$i].subject } elseif ($mergedData[$i].title) { $mergedData[$i].title } else { "" }
$prNum = if ($mergedData[$i].id) { $mergedData[$i].id } elseif ($mergedData[$i].number) { $mergedData[$i].number } else { "" }
$releaseBody += "`n- #$prNum $prTitle"
>>>>>>> master
}
}
@ -458,27 +491,11 @@ if ($DryRun) {
}
}
<<<<<<< HEAD
Log-Title "Complete"
Write-Host " Release: $newVersion" -ForegroundColor Green
Write-Host " PRs merged: $prCount" -ForegroundColor Green
Write-Host " Issues: $issCount" -ForegroundColor Green
Write-Host " Labels: $labelOk applied / $labelFail failed / $labelSkip skipped" -ForegroundColor Green
Write-Host " Assigned: $assignOk applied / $assignFail failed / $assignSkip skipped" -ForegroundColor Green
Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green
Write-Host " Wiki: $wikiTitle" -ForegroundColor Green
=======
$releaseBody += "`n`n---`n*Auto-generated by gitlink-cli community-ops workflow*"
Log-Step "Creating release: $tagName..."
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Repo, "--tag", $tagName, "--name", $releaseName, "--body", $releaseBody
if ($releaseResult -and $releaseResult.ok) { Log-Ok "Release $tagName created successfully" } else { Log-Warn "Release creation may have failed (tag might already exist)" }
# ----------------------------------------------------------------
Log-Title "Community Operations Complete"
# ----------------------------------------------------------------
Write-Host " Issues classified: $totalClassified" -ForegroundColor Green
Write-Host " Closed this week: $closedCount" -ForegroundColor Green
Write-Host " Merged PRs: $mergedCount" -ForegroundColor Green
Write-Host " Weekly report: Published to Wiki" -ForegroundColor Green
Write-Host " Release notes: $tagName" -ForegroundColor Green
>>>>>>> master

View File

@ -94,7 +94,7 @@ PR_CONTRIBUTORS=""
PR_TMP=$(mktemp)
PRS_JSON=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100 2>/dev/null || true)
if [[ -n "$PRS_JSON" ]]; then
echo "$PRS_JSON" | jq -r '(.data.issues // .data.pulls // .data // [])[]? | "\(.subject // .title // "N/A")\t\(.pull_request_number // .number // .id)\t\(.author_login // .author.login // "?")"' 2>/dev/null > "$PR_TMP" || true
echo "$PRS_JSON" | jq -r '(.data.issues // .data.pulls // .data // [])[]? | "\(.name // .subject // .title // "N/A")\t\(.pull_request_number // .number // .id)\t\(.author_login // .author.login // "?")"' 2>/dev/null > "$PR_TMP" || true
while IFS=$'\t' read -r pr_title pr_num pr_author || [[ -n "$pr_title" ]]; do
PR_ITEMS+="- ${pr_title} (#${pr_num}) (@${pr_author})"$'\n'
PR_CONTRIBUTORS+="@${pr_author} "

View File

@ -9,21 +9,22 @@
param(
[string]$Owner = "",
[string]$Repo = "",
[Parameter(Mandatory=$true)]
[string]$IssueNumber,
[string]$IssueNumber = "",
[string]$Assignee = "",
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 01a-issue-triage.ps1 -IssueNumber N [-Owner OWNER] [-Repo REPO] [-Assignee USER] [-DryRun]"
exit 0
}
if (-not $IssueNumber) { Log-Err "IssueNumber is required (use -Help for usage)"; exit 1 }
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
@ -55,6 +56,19 @@ Log-Ok "Issue `"$issueTitle`" by @$issueAuthor"
Log-Step "Discovering label IDs..."
$tagIdMap = @{}
# 优先从项目 issue_tags API 获取
$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags"
if ($tagsApi) {
try {
$tData = ($tagsApi | ConvertFrom-Json).data
$tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() }
foreach ($t in $tagList) {
if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id }
}
} catch {}
}
# 补充:从已有 issue 的 tags 字段收集
foreach ($state in @("open","closed")) {
$sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50
if ($sample) {
@ -126,11 +140,8 @@ if (-not $chosenLabel) {
} else {
Log-Step "Tagging with '$chosenLabel' (ID:$tgtId)..."
$curTagsJson = if ($issueData.tags) {
($issueData.tags | ForEach-Object { "{`"id`":$($_.id),`"name`":`"$($_.name)`"" }) -join "," | ForEach-Object { "[$_]" }
} else { "[]" }
$bodyJson = "{`"tags`":[{`"id`":$tgtId,`"name`":`"$chosenLabel`"}]}"
# GitLink v1 API 使用 issue_tag_ids (整数ID数组),不是 tags (对象数组)
$bodyJson = "{`"issue_tag_ids`":[$tgtId]}"
$tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
if ($tagResult) {
@ -151,12 +162,40 @@ if (-not $targetAssignee) {
} elseif ($DryRun) {
Log-Warn "[DRY RUN] Would assign @$targetAssignee"
} else {
$bodyJson = "{`"subject`":`"$($issueTitle -replace '"','\"')`",`"description`":`"$($issueDesc -replace '"','\"')`",`"assigned_to_id`":`"$targetAssignee`"}"
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
Log-Ok "Assigned: @$targetAssignee"
# assigned_to_id 需要数字用户 ID不是登录名
# 先尝试把输入当纯数字;否则通过 /users/{login} 查询
$assigneeID = $null
$isNumeric = $false
try { [void][int]$targetAssignee; $isNumeric = $true } catch {}
if ($isNumeric) {
$assigneeID = $targetAssignee
} else {
Log-Warn "Assign failed"
Log-Info "Resolving user ID for '$targetAssignee'..."
$userJson = Invoke-GL api,GET,"/users/$targetAssignee"
if ($userJson) {
try {
$userData = ($userJson | ConvertFrom-Json).data
if ($userData.id) {
$assigneeID = [int]$userData.id
} elseif ($userData.user_id) {
$assigneeID = [int]$userData.user_id
}
} catch {}
}
}
if (-not $assigneeID) {
Log-Warn "Cannot resolve user ID for '$targetAssignee' — skipping assign"
} else {
$escTitle = $issueTitle -replace '"','\"'
$escDesc = $issueDesc -replace '"','\"'
$bodyJson = "{`"subject`":`"$escTitle`",`"description`":`"$escDesc`",`"assigner_ids`":[$assigneeID]}"
$result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson
if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) {
Log-Ok "Assigned: @$targetAssignee (ID:$assigneeID)"
} else {
Log-Warn "Assign failed"
}
}
}

View File

@ -60,8 +60,15 @@ log_ok "Issue \"$ISSUE_TITLE\" by @$ISSUE_AUTHOR"
# ── Phase 2: 动态收集标签 ID 映射 ─────────────────────────────────
log_step "Discovering label IDs..."
# 从已有 issue 的 tags 字段收集 name→ID 映射
ID_MAP_FILE=$(mktemp)
# 优先从项目 issue_tags API 获取
TAGS_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/issue_tags" 2>/dev/null || true)
if [[ -n "$TAGS_JSON" ]]; then
echo "$TAGS_JSON" | jq -r '(.data.issue_tags // .data // [])[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
fi
# 补充:从已有 issue 的 tags 字段收集(覆盖 issue_tags API 未返回的情况)
for state in open closed; do
SAMPLE=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 50 2>/dev/null || true)
echo "$SAMPLE" | jq -r '(.data.issues // .data // [])[]?.tags[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true
@ -136,13 +143,9 @@ else
else
log_step "Tagging with '$CHOSEN_LABEL' (ID:$TGT_ID)..."
# 获取当前 tags追加新标签去重
CUR_TAGS=$(echo "$ISSUE_JSON" | jq -c '[.data.tags[]? | {id:.id, name:.name}]' 2>/dev/null || echo "[]")
NEW_TAGS=$(echo "$CUR_TAGS" | jq -c --argjson nt "{\"id\":$TGT_ID,\"name\":\"$CHOSEN_LABEL\"}" \
'. + [$nt] | unique_by(.id)' 2>/dev/null)
TAG_RESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" \
--body "{\"tags\":$NEW_TAGS}" 2>&1) || true
--body "{\"issue_tag_ids\":[$TGT_ID]}" 2>&1) || true
if echo "$TAG_RESP" | jq -e '.ok == true' &>/dev/null; then
log_ok "Tagged: $CHOSEN_LABEL"
@ -162,13 +165,28 @@ if [[ -z "$TARGET_ASSIGNEE" ]]; then
elif [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would assign @$TARGET_ASSIGNEE"
else
BODY="{\"assigned_to_id\":\"$TARGET_ASSIGNEE\"}"
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
log_ok "Assigned: @$TARGET_ASSIGNEE"
# Resolve login name to numeric user ID
ASSIGNEE_ID=""
if [[ "$TARGET_ASSIGNEE" =~ ^[0-9]+$ ]]; then
ASSIGNEE_ID="$TARGET_ASSIGNEE"
else
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
log_warn "Assign failed: $ERR2"
USER_JSON=$(gl_run api GET "/users/$TARGET_ASSIGNEE" 2>/dev/null || true)
if [[ -n "$USER_JSON" ]]; then
ASSIGNEE_ID=$(echo "$USER_JSON" | jq -r '.data.id // .data.user_id // ""' 2>/dev/null || echo "")
fi
fi
if [[ -z "$ASSIGNEE_ID" ]]; then
log_warn "Cannot resolve user ID for '$TARGET_ASSIGNEE' — skipping assign"
else
BODY="{\"assigner_ids\":[$ASSIGNEE_ID]}"
ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true
if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then
log_ok "Assigned: @$TARGET_ASSIGNEE (ID:$ASSIGNEE_ID)"
else
ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown")
log_warn "Assign failed: $ERR2"
fi
fi
fi

View File

@ -22,6 +22,7 @@ PORT = int(os.environ.get("WEBHOOK_PORT", "8080"))
SECRET = os.environ.get("WEBHOOK_SECRET", "")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh")
GATEKEEPER_SCRIPT = os.path.join(SCRIPT_DIR, "02a-pr-gatekeeper.sh")
LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs")
os.makedirs(LOG_DIR, exist_ok=True)
@ -60,24 +61,34 @@ def verify_signature(body: bytes, signature_header: str) -> bool:
def extract_issue_number(payload: dict) -> str:
"""从 webhook payload 提取 Issue 编号"""
# GitLink 格式
issue = payload.get("issue", {})
if issue:
num = issue.get("project_issues_index") or issue.get("number") or issue.get("id")
if num:
log(f"Extracted issue number: #{num}", "OK")
return str(num)
# 直接格式
if "number" in payload:
return str(payload["number"])
if "id" in payload:
return str(payload["id"])
log("Could not extract issue number from payload", "WARN")
return None
def extract_pr_number(payload: dict) -> str:
"""从 webhook payload 提取 PR 编号"""
pr = payload.get("pull_request", {}) or payload.get("pullrequest", {})
if pr:
num = pr.get("number") or pr.get("pull_request_number") or pr.get("id")
if num:
log(f"Extracted PR number: #{num}", "OK")
return str(num)
if "pull_request_number" in payload:
return str(payload["pull_request_number"])
log("Could not extract PR number from payload", "WARN")
return None
def extract_repo_info(payload: dict):
"""从 payload 提取 owner/repo"""
repo = payload.get("repository", {})
@ -116,6 +127,34 @@ def run_triage_async(issue_number: str, owner: str = "", repo: str = ""):
t.start()
def run_gatekeeper_async(pr_number: str, owner: str = "", repo: str = ""):
"""异步调用 PR gatekeeper 脚本"""
def _run():
cmd = ["bash", GATEKEEPER_SCRIPT, "--pr-id", pr_number]
if owner:
cmd += ["--owner", owner]
if repo:
cmd += ["--repo", repo]
log(f"Dispatching gatekeeper: {' '.join(cmd)}", "INFO")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=SCRIPT_DIR)
outfile = os.path.join(LOG_DIR, f"gatekeeper-{pr_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log")
with open(outfile, "w", encoding="utf-8") as f:
f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n")
if result.returncode == 0:
log(f"Gatekeeper PR #{pr_number} completed successfully -> {outfile}", "OK")
else:
log(f"Gatekeeper PR #{pr_number} failed (exit={result.returncode}) -> {outfile}", "ERR")
except subprocess.TimeoutExpired:
log(f"Gatekeeper PR #{pr_number} TIMEOUT after 300s", "ERR")
except Exception as e:
log(f"Gatekeeper PR #{pr_number} error: {e}", "ERR")
t = threading.Thread(target=_run, daemon=True)
t.start()
class WebhookHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
log(f"{self.client_address[0]} - {format % args}", "INFO")
@ -129,7 +168,7 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
</head><body>
<h1>GitLink Community Ops Webhook Listener</h1>
<p class="ok"> Running</p>
<p>Listening for <code>issue</code> events at <code>/webhook</code></p>
<p>Listening for <code>issue</code> and <code>pull_request</code> events at <code>/webhook</code></p>
<p><small>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}</small></p>
</body></html>"""
self._respond(200, html, "text/html")
@ -149,12 +188,6 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
self._respond(403, '{"error":"Invalid signature"}')
return
# 只处理 issue 事件
if "issue" not in event_type.lower():
log(f"Ignoring non-issue event: {event_type}", "INFO")
self._respond(200, '{"status":"ignored","reason":"non-issue event"}')
return
# 解析 payload
try:
payload = json.loads(body)
@ -163,28 +196,45 @@ h1{{color:#333}}.ok{{color:green;font-weight:bold}}code{{background:#f0f0f0;padd
self._respond(400, '{"error":"Invalid JSON"}')
return
# 只处理 "opened" 动作
action = payload.get("action", "")
if action and action != "opened":
log(f"Ignoring issue event with action: {action}", "INFO")
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
is_issue = "issue" in event_type.lower()
is_pr = "pull_request" in event_type.lower() or "pullrequest" in event_type.lower()
if not is_issue and not is_pr:
log(f"Ignoring unsupported event: {event_type}", "INFO")
self._respond(200, '{"status":"ignored","reason":"unsupported event type"}')
return
# 提取 Issue 编号
issue_number = extract_issue_number(payload)
if not issue_number:
self._respond(400, '{"error":"Cannot extract issue number"}')
# 只处理 "opened" 动作
if action and action != "opened":
log(f"Ignoring {event_type} event with action: {action}", "INFO")
self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}')
return
# 提取 owner/repo
owner, repo = extract_repo_info(payload)
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
# ── Issue 事件 → 调起分类 ──
if is_issue:
issue_number = extract_issue_number(payload)
if not issue_number:
self._respond(400, '{"error":"Cannot extract issue number"}')
return
log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK")
run_triage_async(issue_number, owner or "", repo or "")
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
return
# 异步调起分类
run_triage_async(issue_number, owner or "", repo or "")
self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}')
# ── PR 事件 → 调起 gatekeeper ──
if is_pr:
pr_number = extract_pr_number(payload)
if not pr_number:
self._respond(400, '{"error":"Cannot extract PR number"}')
return
log(f"=== New PR #{pr_number} — dispatching to gatekeeper ===", "OK")
run_gatekeeper_async(pr_number, owner or "", repo or "")
self._respond(200, f'{{"status":"accepted","pr_number":{pr_number}}}')
return
def _respond(self, code, body, content_type="application/json"):
self.send_response(code)
@ -202,7 +252,8 @@ def main():
print()
log(f"Starting on port {PORT}")
log(f"Triage script: {TRIAGE_SCRIPT}")
log(f" Issue -> {os.path.basename(TRIAGE_SCRIPT)}")
log(f" PR -> {os.path.basename(GATEKEEPER_SCRIPT)}")
log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}")
print()

View File

@ -18,8 +18,7 @@
#Requires -Version 5.1
param(
[Parameter(Mandatory=$true, HelpMessage="Webhook 回调 URLGitLink 会向此 URL 推送事件")]
[string]$WebhookUrl,
[string]$WebhookUrl = "",
[string]$Owner = "",
[string]$Repo = "",
@ -34,7 +33,7 @@ param(
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]"
@ -66,6 +65,8 @@ if ($Help) {
exit 0
}
if (-not $WebhookUrl) { Log-Err "WebhookUrl is required (use -Help for usage)"; exit 1 }
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo

View File

@ -0,0 +1,567 @@
# ----------------------------------------------------------------
# Scenario 2: Code Quality Gatekeeper
# Flow: PR submit -> AI Review -> Check CI -> Auto-merge if pass
#
# Commands chained:
# 1. pr +list -- list open PRs
# 2. pr +view -- get PR details
# 3. pr +files -- get changed files
# 4. pr +diff -- get diff content
# 5. gitlink-code-review -- AI code review (claude CLI or keyword fallback)
# 6. api POST .../reviews -- post review comment
# 7. ci +builds -- check CI status
# 8. pr +merge -- auto-merge if quality threshold met
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[string]$Repo = "",
[string]$PrId = "",
[int]$Threshold = 80,
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
if ($Help) {
Write-Host "Usage: powershell 02-code-quality-gatekeeper.ps1 -Owner OWNER -Repo REPO [-PrId ID] [-Threshold SCORE] [-DryRun]"
Write-Host ""
Write-Host " -Owner OWNER Repository owner"
Write-Host " -Repo REPO Repository name"
Write-Host " -PrId ID Specific PR to review (default: all open PRs)"
Write-Host " -Threshold SCORE Min quality score to auto-merge (default: 80)"
Write-Host " -DryRun Preview actions without executing"
exit 0
}
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
# ----------------------------------------------------------------
# Default fallback strings (defined at script scope to avoid indented here-string issue)
$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖"
$DefaultReviewPrompt = @'
你是代码审查专家请按 gitlink-code-review skill 的审查维度分析以下 PR
## 审查维度与检查项
{SKILL_DIMENSIONS}
## 评分标准
- 90-100: 优秀可直接合并
- 75-89: 良好建议合并
- 60-74: 一般需要改进
- <60: 较差不建议合并
## 问题严重级别
- CRITICAL: 阻止合并
- HIGH: 强烈建议修复
- MEDIUM: 建议修复
- LOW: 可选修复
## PR 数据
PR 标题: {PR_TITLE}
变更文件:
{FILE_LIST}
代码差异:
{DIFF_CONTENT}
## 输出要求
请严格按以下 JSON 格式输出不要输出其他内容:
{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"}
'@
function Review-PR {
param([string]$PrId)
Log-Title "Reviewing PR #$PrId"
# -- Step 1: Get PR details --
Log-Step "Fetching PR details..."
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
if (-not $prJson) {
Log-Warn "Failed to fetch PR #$PrId, skipping"
return
}
$prData = $prJson.data
$prTitle = if ($prData.title) { $prData.title }
elseif ($prData.subject) { $prData.subject }
elseif ($prData.issue.subject) { $prData.issue.subject }
else { "N/A" }
$prState = if ($prData.state) { $prData.state }
elseif ($prData.status) { $prData.status }
else { "N/A" }
$prAuthor = if ($prData.author.login) { $prData.author.login }
elseif ($prData.author.username) { $prData.author.username }
elseif ($prData.issue.author.login) { $prData.issue.author.login }
else { "N/A" }
Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)"
# -- Step 2: Get changed files --
Log-Step "Fetching changed files..."
$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId
$fileNames = @()
$fileCount = 0
if ($filesJson) {
try {
$filesData = $filesJson | ConvertFrom-Json
if ($filesData.data.files) {
$fileNames = @($filesData.data.files | ForEach-Object {
if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" }
})
$fileCount = $fileNames.Count
}
} catch { }
}
Log-Ok "Changed files: $fileCount"
foreach ($f in $fileNames) { Write-Host " $f" }
# -- Step 3: Get diff --
Log-Step "Fetching diff..."
$diffContent = ""
$diffLines = 0
$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId
if ($diffJson) {
try {
$diffData = $diffJson | ConvertFrom-Json
$diffLines_arr = @()
if ($diffData.data.files) {
foreach ($f in $diffData.data.files) {
if ($f.sections) {
foreach ($s in $f.sections) {
if ($s.lines) {
foreach ($l in $s.lines) {
if ($l.content) { $diffLines_arr += $l.content }
}
}
}
}
}
}
$diffContent = ($diffLines_arr -join "`n")
if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) }
$diffLines = $diffLines_arr.Count
} catch { }
}
Log-Ok "Diff: $diffLines lines"
# -- Step 4: AI-powered code review --
Log-Step "AI analyzing code quality..."
$fileListText = ""
foreach ($f in $fileNames) { $fileListText += "- $f`n" }
$diffTruncated = $diffContent
if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) }
# Load skill dimensions from SKILL.md
$skillDir = "$PSScriptRoot/../skills/gitlink-code-review"
$skillDimensions = ""
if (Test-Path "$skillDir/SKILL.md") {
$skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8
if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') {
$section = $Matches[1]
$dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20)
$skillDimensions = $dimLines -join "`n"
}
}
if (-not $skillDimensions) {
$skillDimensions = $DefaultSkillDimensions
}
$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions
$aiAvailable = $false
$totalScore = 0
$scoreQuality = 25
$scoreSecurity = 25
$scorePerformance = 25
$scoreMaintainability = 25
$issuesFound = @()
$aiPositive = @()
$aiRecommendations = @()
$aiVerdict = "PASS"
$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source
if ($claudePath) {
Log-Info "Calling AI agent for code review (may take 30-60s)..."
try {
$promptFile = [System.IO.Path]::GetTempFileName()
$outFile = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8)
$aiResult = $null
$proc = Start-Process -FilePath $claudePath `
-ArgumentList @("-p", "--output-format", "json") `
-RedirectStandardInput $promptFile `
-RedirectStandardOutput $outFile `
-NoNewWindow -Wait -PassThru
if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) {
$aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8)
try {
$aiOutputJson = $aiOutput | ConvertFrom-Json
$aiResult = $aiOutputJson.result
} catch {
$aiResult = $aiOutput
}
}
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
if ($aiResult) {
# Extract JSON from AI response (may contain markdown wrapping)
$aiJson = $null
$jsonCandidate = Extract-JsonBlock $aiResult
if ($jsonCandidate) {
try {
$testJson = $jsonCandidate | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) {
$aiJson = $testJson
}
} catch { }
}
# Fallback: try direct parse
if (-not $aiJson) {
try {
$testJson = $aiResult | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) {
$aiJson = $testJson
}
} catch { }
}
if ($aiJson) {
$totalScore = [int]($aiJson.total -as [int])
$scoreQuality = [int]($aiJson.quality -as [int])
$scoreSecurity = [int]($aiJson.security -as [int])
$scorePerformance = [int]($aiJson.performance -as [int])
$scoreMaintainability = [int]($aiJson.maintainability -as [int])
$aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" }
if ($aiJson.issues) {
foreach ($issue in $aiJson.issues) {
if ($issue -is [string]) {
$issuesFound += $issue
} else {
$sev = if ($issue.severity) { $issue.severity } else { "?" }
$cat = if ($issue.category) { $issue.category } else { "?" }
$desc = if ($issue.description) { $issue.description }
elseif ($issue.rule) { $issue.rule } else { "unknown" }
$file = if ($issue.file) { " ($($issue.file))" } else { "" }
$sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" }
$issuesFound += "[$sev] ${cat}: $desc${file}${sug}"
}
}
}
if ($aiJson.positive_notes) {
foreach ($note in $aiJson.positive_notes) {
if ($note.description) { $aiPositive += $note.description }
elseif ($note -is [string]) { $aiPositive += $note }
}
}
if ($aiJson.recommendations) {
foreach ($rec in $aiJson.recommendations) {
if ($rec -is [string]) { $aiRecommendations += $rec }
}
}
$aiAvailable = $true
Log-Ok "AI review complete (verdict: $aiVerdict)"
} else {
Log-Warn "Could not parse AI response JSON, falling back to keyword-based"
}
}
} catch {
Log-Warn "AI call failed: $_"
}
}
# -- Fallback: keyword-based heuristics --
if (-not $aiAvailable) {
Log-Warn "AI not available, falling back to keyword-based analysis"
$scoreQuality = 25
$scoreSecurity = 25
$scorePerformance = 25
$scoreMaintainability = 25
$issuesFound = @()
if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') {
$scoreSecurity -= 15
$issuesFound += "SECURITY: 检测到可能的硬编码凭证"
}
if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') {
$scoreSecurity -= 10
$issuesFound += "SECURITY: 检测到危险函数调用"
}
if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') {
$scoreQuality -= 5
$issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释"
}
if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') {
$scorePerformance -= 10
$issuesFound += "PERFORMANCE: 可能的全表查询"
}
if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') {
$scorePerformance -= 5
$issuesFound += "PERFORMANCE: 检测到阻塞式 sleep"
}
if ($fileCount -gt 20) {
$scoreMaintainability -= 10
$issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)"
}
$totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability)
}
# -- Print review report --
Divider
if ($aiAvailable) {
Log-Info "AI Review Report for PR #$PrId"
} else {
Log-Info "Review Report for PR #$PrId (keyword-based)"
}
Write-Host ""
Write-Host " Overall Score: $totalScore / 100"
Write-Host " Code Quality: $scoreQuality / 25"
Write-Host " Security: $scoreSecurity / 25"
Write-Host " Performance: $scorePerformance / 25"
Write-Host " Maintainability: $scoreMaintainability / 25"
Write-Host ""
if ($issuesFound.Count -gt 0) {
Write-Host " Issues Found:"
foreach ($issue in $issuesFound) {
Write-Host " - $issue"
}
Write-Host ""
}
if ($aiPositive.Count -gt 0) {
Write-Host " Positive Notes:"
foreach ($note in $aiPositive) {
Write-Host " + $note"
}
Write-Host ""
}
if ($aiRecommendations.Count -gt 0) {
Write-Host " Recommendations:"
foreach ($rec in $aiRecommendations) {
Write-Host " > $rec"
}
Write-Host ""
}
# -- Step 5: Post review comment --
if ($aiAvailable) {
$reviewHeader = "## AI Code Quality Review - PR #$PrId"
} else {
$reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)"
}
$reviewBody = "$reviewHeader`n`n### Scores`n"
$reviewBody += "| Dimension | Score | Max |`n"
$reviewBody += "|-----------|-------|-----|`n"
$reviewBody += "| Code Quality | $scoreQuality | 25 |`n"
$reviewBody += "| Security | $scoreSecurity | 25 |`n"
$reviewBody += "| Performance | $scorePerformance | 25 |`n"
$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n"
$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n"
$reviewBody += "### Issues Found`n"
if ($issuesFound.Count -gt 0) {
foreach ($issue in $issuesFound) {
$reviewBody += "- $issue`n"
}
} else {
$reviewBody += "No issues found.`n"
}
if ($aiPositive.Count -gt 0) {
$reviewBody += "`n### Positive Notes`n"
foreach ($note in $aiPositive) {
$reviewBody += "- $note`n"
}
}
if ($aiRecommendations.Count -gt 0) {
$reviewBody += "`n### Recommendations`n"
foreach ($rec in $aiRecommendations) {
$reviewBody += "- $rec`n"
}
}
$verdictText = if ($totalScore -ge $Threshold) {
"**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge."
} else {
"**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above."
}
$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
Log-Step "Posting review comment..."
$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" }
$reviewPayload = @{
body = $reviewBody
event = $reviewEvent
} | ConvertTo-Json -Compress
$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload
if ($reviewResult) {
try {
$reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true)
} catch { $reviewOk = $false }
if ($reviewOk) {
Log-Ok "Review posted"
} else {
Log-Warn "Review post may have failed (review API might not be available)"
}
} else {
Log-Warn "Review post may have failed"
}
# -- Step 6: Check CI --
Log-Step "Checking CI build status..."
$ciPresent = $false
$ciPassed = $true
$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo
if ($ciJson) {
try {
$ciData = $ciJson | ConvertFrom-Json
$builds = @()
if ($ciData.data.builds) { $builds = @($ciData.data.builds) }
elseif ($ciData.data -is [array]) { $builds = @($ciData.data) }
if ($builds.Count -gt 0) {
$ciPresent = $true
foreach ($b in $builds) {
$status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" }
$name = if ($b.name) { $b.name } else { "build" }
if ($status -notin @("success", "passed", "completed")) {
$ciPassed = $false
Log-Warn "CI '$name' status: $status"
} else {
Log-Ok "CI '$name' status: $status"
}
}
}
} catch { }
}
if (-not $ciPresent) { Log-Info "No CI builds found" }
# -- Step 7: Auto-merge --
if ($totalScore -ge $Threshold -and $ciPassed) {
Log-Step "Quality score $totalScore >= $Threshold and CI passed"
if ($DryRun) {
Log-Warn "[DRY RUN] Would auto-merge PR #$PrId"
} else {
Log-Step "Auto-merging PR #$PrId..."
$mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge
if ($mergeResult) {
try {
$mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true)
} catch { $mergeOk = $false }
if ($mergeOk) {
Log-Ok "PR #$PrId merged successfully!"
} else {
Log-Err "Auto-merge failed"
}
} else {
Log-Err "Auto-merge failed"
}
}
} else {
Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)"
}
Write-Host ""
}
# ----------------------------------------------------------------
function Extract-JsonBlock {
param([string]$Text)
# Find JSON by balanced brace matching
$depth = 0
$start = -1
$results = @()
for ($i = 0; $i -lt $Text.Length; $i++) {
$c = $Text[$i]
if ($c -eq '{') {
if ($depth -eq 0) { $start = $i }
$depth++
} elseif ($c -eq '}') {
$depth--
if ($depth -eq 0 -and $start -ge 0) {
$results += $Text.Substring($start, $i - $start + 1)
$start = -1
}
}
}
# Return the last valid JSON object (usually the most complete)
for ($i = $results.Count - 1; $i -ge 0; $i--) {
try {
$obj = $results[$i] | ConvertFrom-Json
if ($obj.total -ne $null -and $obj.verdict) {
return $results[$i]
}
} catch { }
}
return $null
}
# ================================================================
# Main
# ================================================================
Log-Title "Code Quality Gatekeeper"
if ($PrId) {
Review-PR $PrId
} else {
Log-Step "Fetching open PRs..."
$prsResult = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,open,--limit,50
$prList = @()
if ($prsResult) {
try {
$prsJson = $prsResult | ConvertFrom-Json
$prData = $prsJson.data
if ($prData.issues) { $prList = @($prData.issues) }
elseif ($prData.pulls) { $prList = @($prData.pulls) }
elseif ($prData -is [array]) { $prList = $prData }
} catch { }
}
$prCount = $prList.Count
Log-Ok "Found $prCount open PRs"
if ($prCount -eq 0) {
Log-Info "No open PRs to review"
exit 0
}
$reviewed = 0
$passed = 0
$failed = 0
foreach ($pr in $prList) {
$prNum = if ($pr.pull_request_number) { $pr.pull_request_number }
elseif ($pr.number) { $pr.number }
elseif ($pr.id) { $pr.id }
else { $null }
if (-not $prNum) { continue }
Review-PR $prNum
$reviewed++
}
Log-Title "Gatekeeper Summary"
Write-Host " PRs Reviewed: $reviewed" -ForegroundColor Green
Write-Host " Threshold: $Threshold" -ForegroundColor Green
}

View File

@ -403,9 +403,9 @@ $(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
log_step "Posting review comment..."
REVIEW_EVENT=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "APPROVE"; else echo "COMMENT"; fi)
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg event "$REVIEW_EVENT" \
'{body: $body, event: $event}')
REVIEW_STATE=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "approved"; else echo "commented"; fi)
REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg state "$REVIEW_STATE" \
'{body: $body, state: $state}')
REVIEW_RESULT=$(gl_run api POST "/$OWNER/$REPO/pulls/$pr_id/reviews" \
--body "$REVIEW_JSON" 2>&1) || true

View File

@ -0,0 +1,413 @@
# ----------------------------------------------------------------
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[string]$Repo = "",
[string]$PrId = "",
[int]$Threshold = 80,
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
if ($Help) {
Write-Host "Usage: powershell 02a-pr-gatekeeper.ps1 -PrId ID [-Owner OWNER] [-Repo REPO] [-Threshold SCORE] [-DryRun]"
exit 0
}
if (-not $PrId) { Log-Err "PrId is required (use -Help for usage)"; exit 1 }
Check-Auth
$r = Resolve-OwnerRepo $Owner $Repo
$Owner = $r.Owner; $Repo = $r.Repo
# Default fallback strings
$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖"
$DefaultReviewPrompt = @'
你是代码审查专家请按 gitlink-code-review skill 的审查维度分析以下 PR
## 审查维度与检查项
{SKILL_DIMENSIONS}
## 评分标准
- 90-100: 优秀可直接合并
- 75-89: 良好建议合并
- 60-74: 一般需要改进
- <60: 较差不建议合并
## 问题严重级别
- CRITICAL: 阻止合并
- HIGH: 强烈建议修复
- MEDIUM: 建议修复
- LOW: 可选修复
## PR 数据
PR 标题: {PR_TITLE}
变更文件:
{FILE_LIST}
代码差异:
{DIFF_CONTENT}
## 输出要求
请严格按以下 JSON 格式输出不要输出其他内容:
{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"}
'@
function Extract-JsonBlock {
param([string]$Text)
$depth = 0; $start = -1; $results = @()
for ($i = 0; $i -lt $Text.Length; $i++) {
$c = $Text[$i]
if ($c -eq '{') {
if ($depth -eq 0) { $start = $i }
$depth++
} elseif ($c -eq '}') {
$depth--
if ($depth -eq 0 -and $start -ge 0) {
$results += $Text.Substring($start, $i - $start + 1)
$start = -1
}
}
}
for ($i = $results.Count - 1; $i -ge 0; $i--) {
try {
$obj = $results[$i] | ConvertFrom-Json
if ($obj.total -ne $null -and $obj.verdict) { return $results[$i] }
} catch { }
}
return $null
}
# ================================================================
Log-Title "PR Gatekeeper: #$PrId ($Owner/$Repo)"
# -- Step 1: 拉取 PR 详情 --
Log-Step "Fetching PR details..."
$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId
if (-not $prJson) { Log-Err "Failed to fetch PR #$PrId"; exit 1 }
$prData = $prJson.data
$prTitle = if ($prData.title) { $prData.title }
elseif ($prData.subject) { $prData.subject }
elseif ($prData.issue.subject) { $prData.issue.subject }
else { "N/A" }
$prState = if ($prData.state) { $prData.state }
elseif ($prData.status) { $prData.status }
else { "N/A" }
$prAuthor = if ($prData.author.login) { $prData.author.login }
elseif ($prData.author.username) { $prData.author.username }
elseif ($prData.issue.author.login) { $prData.issue.author.login }
else { "N/A" }
Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)"
# -- Step 2: 获取变更文件列表 --
Log-Step "Fetching changed files..."
$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId
$fileNames = @(); $fileCount = 0
if ($filesJson) {
try {
$filesData = $filesJson | ConvertFrom-Json
if ($filesData.data.files) {
$fileNames = @($filesData.data.files | ForEach-Object {
if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" }
})
$fileCount = $fileNames.Count
}
} catch { }
}
Log-Ok "Changed files: $fileCount"
foreach ($f in $fileNames) { Write-Host " $f" }
# -- Step 3: 获取 diff --
Log-Step "Fetching diff..."
$diffContent = ""; $diffLines = 0
$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId
if ($diffJson) {
try {
$diffData = $diffJson | ConvertFrom-Json
$diffLines_arr = @()
if ($diffData.data.files) {
foreach ($f in $diffData.data.files) {
if ($f.sections) {
foreach ($s in $f.sections) {
if ($s.lines) {
foreach ($l in $s.lines) {
if ($l.content) { $diffLines_arr += $l.content }
}
}
}
}
}
}
$diffContent = ($diffLines_arr -join "`n")
if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) }
$diffLines = $diffLines_arr.Count
} catch { }
}
Log-Ok "Diff: $diffLines lines"
# -- Step 4: AI 代码审查 --
Log-Step "AI analyzing code quality..."
$fileListText = ""
foreach ($f in $fileNames) { $fileListText += "- $f`n" }
$diffTruncated = $diffContent
if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) }
$skillDir = "$PSScriptRoot/../skills/gitlink-code-review"
$skillDimensions = ""
if (Test-Path "$skillDir/SKILL.md") {
$skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8
if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') {
$section = $Matches[1]
$dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20)
$skillDimensions = $dimLines -join "`n"
}
}
if (-not $skillDimensions) { $skillDimensions = $DefaultSkillDimensions }
$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions
$aiAvailable = $false
$totalScore = 0
$scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25
$issuesFound = @(); $aiPositive = @(); $aiRecommendations = @(); $aiVerdict = "PASS"
$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source
if ($claudePath) {
Log-Info "Calling AI agent for code review (may take 30-60s)..."
try {
$promptFile = [System.IO.Path]::GetTempFileName()
$outFile = [System.IO.Path]::GetTempFileName()
[System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8)
$proc = Start-Process -FilePath $claudePath `
-ArgumentList @("-p", "--output-format", "json") `
-RedirectStandardInput $promptFile `
-RedirectStandardOutput $outFile `
-NoNewWindow -Wait -PassThru
if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) {
$aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8)
try { $aiResult = ($aiOutput | ConvertFrom-Json).result } catch { $aiResult = $aiOutput }
}
Remove-Item $promptFile -Force -ErrorAction SilentlyContinue
Remove-Item $outFile -Force -ErrorAction SilentlyContinue
if ($aiResult) {
$aiJson = $null
$jsonCandidate = Extract-JsonBlock $aiResult
if ($jsonCandidate) {
try {
$testJson = $jsonCandidate | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson }
} catch { }
}
if (-not $aiJson) {
try {
$testJson = $aiResult | ConvertFrom-Json
if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson }
} catch { }
}
if ($aiJson) {
$totalScore = [int]($aiJson.total -as [int])
$scoreQuality = [int]($aiJson.quality -as [int])
$scoreSecurity = [int]($aiJson.security -as [int])
$scorePerformance = [int]($aiJson.performance -as [int])
$scoreMaintainability = [int]($aiJson.maintainability -as [int])
$aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" }
if ($aiJson.issues) {
foreach ($issue in $aiJson.issues) {
if ($issue -is [string]) { $issuesFound += $issue }
else {
$sev = if ($issue.severity) { $issue.severity } else { "?" }
$cat = if ($issue.category) { $issue.category } else { "?" }
$desc = if ($issue.description) { $issue.description }
elseif ($issue.rule) { $issue.rule } else { "unknown" }
$file = if ($issue.file) { " ($($issue.file))" } else { "" }
$sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" }
$issuesFound += "[$sev] ${cat}: $desc${file}${sug}"
}
}
}
if ($aiJson.positive_notes) {
foreach ($note in $aiJson.positive_notes) {
if ($note.description) { $aiPositive += $note.description }
elseif ($note -is [string]) { $aiPositive += $note }
}
}
if ($aiJson.recommendations) {
foreach ($rec in $aiJson.recommendations) {
if ($rec -is [string]) { $aiRecommendations += $rec }
}
}
$aiAvailable = $true
Log-Ok "AI review complete (verdict: $aiVerdict)"
} else {
Log-Warn "Could not parse AI response JSON, falling back to keyword-based"
}
}
} catch { Log-Warn "AI call failed: $_" }
}
# -- Fallback: keyword-based --
if (-not $aiAvailable) {
Log-Warn "AI not available, falling back to keyword-based analysis"
$scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25
$issuesFound = @()
if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') {
$scoreSecurity -= 15; $issuesFound += "SECURITY: 检测到可能的硬编码凭证"
}
if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') {
$scoreSecurity -= 10; $issuesFound += "SECURITY: 检测到危险函数调用"
}
if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') {
$scoreQuality -= 5; $issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释"
}
if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') {
$scorePerformance -= 10; $issuesFound += "PERFORMANCE: 可能的全表查询"
}
if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') {
$scorePerformance -= 5; $issuesFound += "PERFORMANCE: 检测到阻塞式 sleep"
}
if ($fileCount -gt 20) {
$scoreMaintainability -= 10; $issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)"
}
$totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability)
}
# -- 打印审查报告 --
Divider
if ($aiAvailable) { Log-Info "AI Review Report for PR #$PrId" }
else { Log-Info "Review Report for PR #$PrId (keyword-based)" }
Write-Host ""
Write-Host " Overall Score: $totalScore / 100"
Write-Host " Code Quality: $scoreQuality / 25"
Write-Host " Security: $scoreSecurity / 25"
Write-Host " Performance: $scorePerformance / 25"
Write-Host " Maintainability: $scoreMaintainability / 25"
Write-Host ""
if ($issuesFound.Count -gt 0) {
Write-Host " Issues Found:"
foreach ($issue in $issuesFound) { Write-Host " - $issue" }
Write-Host ""
}
if ($aiPositive.Count -gt 0) {
Write-Host " Positive Notes:"
foreach ($note in $aiPositive) { Write-Host " + $note" }
Write-Host ""
}
if ($aiRecommendations.Count -gt 0) {
Write-Host " Recommendations:"
foreach ($rec in $aiRecommendations) { Write-Host " > $rec" }
Write-Host ""
}
# -- Step 5: 发布审查评论 --
if ($aiAvailable) { $reviewHeader = "## AI Code Quality Review - PR #$PrId" }
else { $reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)" }
$reviewBody = "$reviewHeader`n`n### Scores`n"
$reviewBody += "| Dimension | Score | Max |`n"
$reviewBody += "|-----------|-------|-----|`n"
$reviewBody += "| Code Quality | $scoreQuality | 25 |`n"
$reviewBody += "| Security | $scoreSecurity | 25 |`n"
$reviewBody += "| Performance | $scorePerformance | 25 |`n"
$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n"
$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n"
$reviewBody += "### Issues Found`n"
if ($issuesFound.Count -gt 0) {
foreach ($issue in $issuesFound) { $reviewBody += "- $issue`n" }
} else { $reviewBody += "No issues found.`n" }
if ($aiPositive.Count -gt 0) {
$reviewBody += "`n### Positive Notes`n"
foreach ($note in $aiPositive) { $reviewBody += "- $note`n" }
}
if ($aiRecommendations.Count -gt 0) {
$reviewBody += "`n### Recommendations`n"
foreach ($rec in $aiRecommendations) { $reviewBody += "- $rec`n" }
}
$verdictText = if ($totalScore -ge $Threshold) {
"**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge."
} else {
"**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above."
}
$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
Log-Step "Posting review comment..."
$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" }
$reviewPayload = @{ body = $reviewBody; event = $reviewEvent } | ConvertTo-Json -Compress
$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload
if ($reviewResult) {
try { $reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true) } catch { $reviewOk = $false }
if ($reviewOk) { Log-Ok "Review posted" }
else { Log-Warn "Review post may have failed (review API might not be available)" }
} else { Log-Warn "Review post may have failed" }
# -- Step 6: 检查 CI 状态 --
Log-Step "Checking CI build status..."
$ciPresent = $false; $ciPassed = $true
$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo
if ($ciJson) {
try {
$ciData = $ciJson | ConvertFrom-Json
$builds = @()
if ($ciData.data.builds) { $builds = @($ciData.data.builds) }
elseif ($ciData.data -is [array]) { $builds = @($ciData.data) }
if ($builds.Count -gt 0) {
$ciPresent = $true
foreach ($b in $builds) {
$status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" }
$name = if ($b.name) { $b.name } else { "build" }
if ($status -notin @("success", "passed", "completed")) {
$ciPassed = $false; Log-Warn "CI '$name' status: $status"
} else { Log-Ok "CI '$name' status: $status" }
}
}
} catch { }
}
if (-not $ciPresent) { Log-Info "No CI builds found" }
# -- Step 7: 质量达标 → 自动合并 --
if ($totalScore -ge $Threshold -and $ciPassed) {
Log-Step "Quality score $totalScore >= $Threshold and CI passed"
if ($DryRun) {
Log-Warn "[DRY RUN] Would auto-merge PR #$PrId"
} else {
Log-Step "Auto-merging PR #$PrId..."
$mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge
if ($mergeResult) {
try { $mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true) } catch { $mergeOk = $false }
if ($mergeOk) { Log-Ok "PR #$PrId merged successfully!" }
else { Log-Err "Auto-merge failed" }
} else { Log-Err "Auto-merge failed" }
}
} else {
Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)"
}
# ── Complete ──────────────────────────────────────────────────────
Log-Title "Gatekeeper Complete"
Write-Host " PR: #$PrId - $prTitle" -ForegroundColor Green
Write-Host " Author: @$prAuthor" -ForegroundColor Green
Write-Host " Score: $totalScore / 100" -ForegroundColor Green
Write-Host " CI Passed: $ciPassed" -ForegroundColor Green

View File

@ -0,0 +1,603 @@
#!/usr/bin/env bash
# ================================================================
# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发)
# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并
# ================================================================
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
PR_ID=""
OWNER=""
REPO=""
THRESHOLD=80
DRY_RUN=false
usage() {
echo "Usage: $0 --pr-id N [--owner OWNER] [--repo REPO] [--threshold SCORE] [--dry-run]"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--pr-id) PR_ID="$2"; shift 2 ;;
--owner) OWNER="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--threshold) THRESHOLD="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
esac
done
[[ -z "$PR_ID" ]] && { log_err "--pr-id is required"; usage; }
check_auth
require_owner_repo
# ================================================================
log_title "PR Gatekeeper: #$PR_ID ($OWNER/$REPO)"
# ── Step 1: 拉取 PR 详情 ─────────────────────────────────────────
log_step "Fetching PR details..."
PR_JSON=$(gl_check pr +view --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
[[ -z "$PR_JSON" ]] && { log_err "Failed to fetch PR #$PR_ID"; exit 1; }
PR_TITLE=$(echo "$PR_JSON" | jq -r '.data.title // .data.subject // .data.issue.subject // "N/A"')
PR_STATE=$(echo "$PR_JSON" | jq -r '.data.state // .data.status // "N/A"')
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.data.author.login // .data.author.username // .data.issue.author.login // "N/A"')
ISSUE_ID=$(echo "$PR_JSON" | jq -r '.data.id // .data.issue.id // empty')
PR_HEAD=$(echo "$PR_JSON" | jq -r '.data.pull_request.head // empty')
log_ok "PR #$PR_ID: \"$PR_TITLE\" by @$PR_AUTHOR (state: $PR_STATE, branch: $PR_HEAD)"
# ── Step 2: 获取变更文件列表 ──────────────────────────────────────
log_step "Fetching changed files..."
FILES_JSON=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
FILE_COUNT=0
if echo "$FILES_JSON" | jq empty 2>/dev/null; then
FILE_COUNT=$(echo "$FILES_JSON" | jq '.data.files | length' 2>/dev/null || echo "0")
fi
log_ok "Changed files: $FILE_COUNT"
FILE_LIST=""
if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then
for i in $(seq 0 $((FILE_COUNT - 1))); do
FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown")
echo " $FNAME"
FILE_LIST+="- $FNAME"$'\n'
done
fi
# ── Step 3: 获取 diff ────────────────────────────────────────────
log_step "Fetching diff..."
DIFF_JSON=$(gl_run pr +diff --owner "$OWNER" --repo "$REPO" --id "$PR_ID")
DIFF_CONTENT=""
if echo "$DIFF_JSON" | jq empty 2>/dev/null; then
DIFF_CONTENT=$(echo "$DIFF_JSON" | jq -r '
[.data.files[]?.sections[]?.lines[]?.content // empty] | join("\n")
' 2>/dev/null | head -c 5000 || true)
fi
DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l)
log_ok "Diff: $DIFF_LINES lines"
# ── Step 4: AI 代码审查 ───────────────────────────────────────────
log_step "AI analyzing code quality..."
DIFF_TRUNCATED=$(echo "$DIFF_CONTENT" | head -c 4000)
# Load skill dimensions from SKILL.md
SKILL_DIR="$SCRIPT_DIR/../skills/gitlink-code-review"
SKILL_DIMENSIONS=""
if [[ -f "$SKILL_DIR/SKILL.md" ]]; then
SKILL_DIMENSIONS=$(sed -n '/^## 📊 审查维度/,/^## 🔧 使用方式/p' "$SKILL_DIR/SKILL.md" | grep '^\- \*\*' | head -20)
fi
REVIEW_PROMPT="你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。
## 审查维度与检查项
${SKILL_DIMENSIONS:-1. 代码质量: 复杂度、命名、注释、格式
2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证
3. 性能: 循环效率、资源泄漏、N+1查询、内存
4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖}
## 评分标准
- 90-100: 优秀,可直接合并
- 75-89: 良好,建议合并
- 60-74: 一般,需要改进
- <60: 较差,不建议合并
## 问题严重级别
- CRITICAL: 阻止合并
- HIGH: 强烈建议修复
- MEDIUM: 建议修复
- LOW: 可选修复
## PR 数据
PR 标题: $PR_TITLE
变更文件:
$FILE_LIST
代码差异:
$DIFF_TRUNCATED
## 输出要求
请严格按以下 JSON 格式输出,不要输出其他内容:
{\"total\": <0-100>, \"quality\": <0-25>, \"security\": <0-25>, \"performance\": <0-25>, \"maintainability\": <0-25>, \"issues\": [{\"severity\": \"HIGH/MEDIUM/LOW\", \"category\": \"quality/security/performance/maintainability\", \"file\": \"文件路径\", \"rule\": \"规则名\", \"description\": \"问题描述\", \"suggestion\": \"修复建议\"}], \"positive_notes\": [{\"description\": \"优秀实践描述\"}], \"recommendations\": [\"改进建议1\"], \"verdict\": \"PASS或FAIL\"}"
AI_AVAILABLE=false
SCORE_QUALITY=25
SCORE_SECURITY=25
SCORE_PERFORMANCE=25
SCORE_MAINTAINABILITY=25
ISSUES_FOUND=()
AI_POSITIVE=()
AI_RECOMMENDATIONS=()
TOTAL_SCORE=0
if command -v claude &>/dev/null; then
log_info "Calling AI agent for code review (may take 30-60s)..."
PROMPT_FILE=$(mktemp)
AI_OUT_FILE=$(mktemp)
echo "$REVIEW_PROMPT" > "$PROMPT_FILE"
if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then
export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")"
fi
AI_EXIT=0
(cat "$PROMPT_FILE" | timeout 300 claude -p --output-format json > "$AI_OUT_FILE" 2>/dev/null) || AI_EXIT=$?
if [[ $AI_EXIT -eq 0 ]] && [[ -s "$AI_OUT_FILE" ]]; then
AI_RESULT=$(jq -r '.result // empty' "$AI_OUT_FILE" 2>/dev/null)
else
log_warn "AI call failed (exit: $AI_EXIT), falling back to keyword-based"
AI_RESULT=""
fi
rm -f "$PROMPT_FILE" "$AI_OUT_FILE"
if [[ -n "$AI_RESULT" ]]; then
AI_JSON=""
if command -v python3 &>/dev/null; then
AI_JSON=$(python3 -c "
import sys, json
text = sys.stdin.read()
depth = 0
start = -1
results = []
for i, c in enumerate(text):
if c == '{':
if depth == 0: start = i
depth += 1
elif c == '}':
depth -= 1
if depth == 0 and start >= 0:
results.append(text[start:i+1])
start = -1
for m in reversed(results):
try:
obj = json.loads(m)
if 'total' in obj and 'verdict' in obj:
print(json.dumps(obj))
break
except: pass
" <<< "$AI_RESULT" 2>/dev/null)
fi
if [[ -z "$AI_JSON" ]]; then
AI_JSON=$(echo "$AI_RESULT" | grep -oP '\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | tail -1)
fi
if [[ -n "$AI_JSON" ]] && echo "$AI_JSON" | jq empty 2>/dev/null; then
TOTAL_SCORE=$(echo "$AI_JSON" | jq -r '.total // 0' 2>/dev/null)
SCORE_QUALITY=$(echo "$AI_JSON" | jq -r '.quality // 0' 2>/dev/null)
SCORE_SECURITY=$(echo "$AI_JSON" | jq -r '.security // 0' 2>/dev/null)
SCORE_PERFORMANCE=$(echo "$AI_JSON" | jq -r '.performance // 0' 2>/dev/null)
SCORE_MAINTAINABILITY=$(echo "$AI_JSON" | jq -r '.maintainability // 0' 2>/dev/null)
AI_VERDICT=$(echo "$AI_JSON" | jq -r '.verdict // "PASS"' 2>/dev/null)
ISSUE_COUNT=$(echo "$AI_JSON" | jq '.issues | length' 2>/dev/null || echo "0")
if [[ "$ISSUE_COUNT" -gt 0 ]] && [[ "$ISSUE_COUNT" != "null" ]]; then
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
issue=$(echo "$AI_JSON" | jq -r '
if .issues['"$idx"'] | type == "object" then
"[" + (.issues['"$idx"'].severity // "?") + "] " +
(.issues['"$idx"'].category // "?") + ": " +
(.issues['"$idx"'].description // .issues['"$idx"'].rule // "unknown") +
(if .issues['"$idx"'].file then " (" + .issues['"$idx"'].file + ")" else "" end) +
(if .issues['"$idx"'].suggestion then " -> " + .issues['"$idx"'].suggestion else "" end)
else .issues['"$idx"'] // empty end' 2>/dev/null)
[[ -n "$issue" ]] && ISSUES_FOUND+=("$issue")
done
fi
POS_COUNT=$(echo "$AI_JSON" | jq '.positive_notes | length' 2>/dev/null || echo "0")
if [[ "$POS_COUNT" -gt 0 ]] && [[ "$POS_COUNT" != "null" ]]; then
for idx in $(seq 0 $((POS_COUNT - 1))); do
note=$(echo "$AI_JSON" | jq -r '.positive_notes['"$idx"'].description // empty' 2>/dev/null)
[[ -n "$note" ]] && AI_POSITIVE+=("$note")
done
fi
REC_COUNT=$(echo "$AI_JSON" | jq '.recommendations | length' 2>/dev/null || echo "0")
if [[ "$REC_COUNT" -gt 0 ]] && [[ "$REC_COUNT" != "null" ]]; then
for idx in $(seq 0 $((REC_COUNT - 1))); do
rec=$(echo "$AI_JSON" | jq -r '.recommendations['"$idx"'] // empty' 2>/dev/null)
[[ -n "$rec" ]] && AI_RECOMMENDATIONS+=("$rec")
done
fi
AI_AVAILABLE=true
log_ok "AI review complete (verdict: $AI_VERDICT)"
else
log_warn "Could not parse AI response JSON, falling back to keyword-based"
fi
fi
fi
# Fallback: keyword-based heuristics
if [[ "$AI_AVAILABLE" != "true" ]]; then
log_warn "AI not available, falling back to keyword-based analysis"
SCORE_QUALITY=25
SCORE_SECURITY=25
SCORE_PERFORMANCE=25
SCORE_MAINTAINABILITY=25
ISSUES_FOUND=()
if echo "$DIFF_CONTENT" | grep -qiE 'password|secret|token|api_key|apikey|private_key'; then
SCORE_SECURITY=$((SCORE_SECURITY - 15))
ISSUES_FOUND+=("SECURITY: 检测到可能的硬编码凭证")
fi
if echo "$DIFF_CONTENT" | grep -qiE 'eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call'; then
SCORE_SECURITY=$((SCORE_SECURITY - 10))
ISSUES_FOUND+=("SECURITY: 检测到危险函数调用")
fi
if echo "$DIFF_CONTENT" | grep -qiE 'TODO|FIXME|HACK|XXX'; then
SCORE_QUALITY=$((SCORE_QUALITY - 5))
ISSUES_FOUND+=("QUALITY: 存在 TODO/FIXME/HACK 注释")
fi
if echo "$DIFF_CONTENT" | grep -qiE 'SELECT \*|\.findAll\(\)|\.all\(\)'; then
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 10))
ISSUES_FOUND+=("PERFORMANCE: 可能的全表查询")
fi
if echo "$DIFF_CONTENT" | grep -qiE 'sleep\(|time\.sleep|Thread\.sleep'; then
SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 5))
ISSUES_FOUND+=("PERFORMANCE: 检测到阻塞式 sleep")
fi
if [[ "$FILE_COUNT" -gt 20 ]]; then
SCORE_MAINTAINABILITY=$((SCORE_MAINTAINABILITY - 10))
ISSUES_FOUND+=("MAINTAINABILITY: 变更文件数量过多 ($FILE_COUNT)")
fi
TOTAL_SCORE=$((SCORE_QUALITY + SCORE_SECURITY + SCORE_PERFORMANCE + SCORE_MAINTAINABILITY))
TOTAL_SCORE=$((TOTAL_SCORE < 0 ? 0 : TOTAL_SCORE))
fi
# ── 打印审查报告 ──────────────────────────────────────────────────
divider
if [[ "$AI_AVAILABLE" == "true" ]]; then
log_info "AI Review Report for PR #$PR_ID"
else
log_info "Review Report for PR #$PR_ID (keyword-based)"
fi
echo ""
echo " Overall Score: $TOTAL_SCORE / 100"
echo " Code Quality: $SCORE_QUALITY / 25"
echo " Security: $SCORE_SECURITY / 25"
echo " Performance: $SCORE_PERFORMANCE / 25"
echo " Maintainability: $SCORE_MAINTAINABILITY / 25"
echo ""
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
echo " Issues Found:"
for issue in "${ISSUES_FOUND[@]}"; do
echo " - $issue"
done
echo ""
fi
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
echo " Positive Notes:"
for note in "${AI_POSITIVE[@]}"; do
echo " + $note"
done
echo ""
fi
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
echo " Recommendations:"
for rec in "${AI_RECOMMENDATIONS[@]}"; do
echo " > $rec"
done
echo ""
fi
# ── Step 5: 发布审查评论 ──────────────────────────────────────────
if [[ "$AI_AVAILABLE" == "true" ]]; then
REVIEW_HEADER="## AI Code Quality Review - PR #$PR_ID"
else
REVIEW_HEADER="## Code Quality Review - PR #$PR_ID (keyword-based)"
fi
REVIEW_BODY="$REVIEW_HEADER
### Scores
| Dimension | Score | Max |
|-----------|-------|-----|
| Code Quality | $SCORE_QUALITY | 25 |
| Security | $SCORE_SECURITY | 25 |
| Performance | $SCORE_PERFORMANCE | 25 |
| Maintainability | $SCORE_MAINTAINABILITY | 25 |
| **Total** | **$TOTAL_SCORE** | **100** |
### Issues Found"
if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then
for issue in "${ISSUES_FOUND[@]}"; do
REVIEW_BODY+=$'\n'"- $issue"
done
else
REVIEW_BODY+=$'\n'"No issues found."
fi
if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then
REVIEW_BODY+=$'\n'$'\n'"### Positive Notes"
for note in "${AI_POSITIVE[@]}"; do
REVIEW_BODY+=$'\n'"- $note"
done
fi
if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then
REVIEW_BODY+=$'\n'$'\n'"### Recommendations"
for rec in "${AI_RECOMMENDATIONS[@]}"; do
REVIEW_BODY+=$'\n'"- $rec"
done
fi
REVIEW_BODY+="
### Verdict
$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE >= threshold $THRESHOLD. Ready to merge."; else echo "**FAIL** - Score $TOTAL_SCORE < threshold $THRESHOLD. Please address the issues above."; fi)
---
*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*"
log_step "Posting review comment..."
REVIEW_JSON=$(jq -n --arg notes "$REVIEW_BODY" '{notes: $notes}')
REVIEW_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
--body "$REVIEW_JSON" 2>&1) || true
if [[ "$(json_ok "$REVIEW_RESULT")" == "true" ]]; then
log_ok "Review comment posted"
else
log_warn "Review comment may have failed"
fi
# ── Step 6: 本地 CI (编译 + 测试) ─────────────────────────────────
log_step "Running local CI: build & test..."
REPOS_DIR="/opt/gitlink-webhook/repos"
REPO_CLONE_DIR="$REPOS_DIR/$REPO"
GIT_URL="https://www.gitlink.org.cn/${OWNER}/${REPO}.git"
BUILD_LOG=$(mktemp)
TEST_LOG=$(mktemp)
CI_BUILD_PASSED=false
CI_TEST_PASSED=false
CI_PASSED=false
if [[ -z "$PR_HEAD" ]]; then
log_warn "Cannot determine PR head branch, skipping CI"
CI_BUILD_OUTPUT="CI skipped: unknown PR branch"
CI_TEST_OUTPUT="CI skipped: unknown PR branch"
else
# Clone repo if not exists, otherwise fetch latest
if [[ ! -d "$REPO_CLONE_DIR/.git" ]]; then
mkdir -p "$REPOS_DIR"
log_info "Cloning $GIT_URL..."
git clone "$GIT_URL" "$REPO_CLONE_DIR" 2>&1 || true
fi
{
cd "$REPO_CLONE_DIR"
log_info "Fetching and checking out branch '$PR_HEAD'..."
git fetch origin 2>&1 || true
git checkout "$PR_HEAD" 2>&1 || true
git pull origin "$PR_HEAD" 2>&1 || true
log_info "Running: go build ./..."
if go build ./... >> "$BUILD_LOG" 2>&1; then
CI_BUILD_PASSED=true
log_ok "Build passed"
else
log_err "Build failed"
fi
log_info "Running: go test ./..."
if go test ./... >> "$TEST_LOG" 2>&1; then
CI_TEST_PASSED=true
log_ok "Tests passed"
else
log_err "Tests failed"
fi
}
CI_BUILD_OUTPUT=$(tail -c 3000 "$BUILD_LOG" 2>/dev/null || echo "no output")
CI_TEST_OUTPUT=$(tail -c 3000 "$TEST_LOG" 2>/dev/null || echo "no output")
fi
if $CI_BUILD_PASSED && $CI_TEST_PASSED; then
CI_PASSED=true
CI_SUMMARY="| Build | PASSED | | |\n| Tests | PASSED | | |"
elif $CI_BUILD_PASSED && ! $CI_TEST_PASSED; then
CI_SUMMARY="| Build | PASSED | | |\n| Tests | **FAILED** | | |"
elif ! $CI_BUILD_PASSED && $CI_TEST_PASSED; then
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | PASSED | | |"
else
CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | **FAILED** | | |"
fi
# Post CI results as a follow-up comment
log_step "Posting CI results..."
CI_BODY="## CI Results - PR #$PR_ID
### Build & Test
| Stage | Status |
|-------|--------|
$CI_SUMMARY
### Build Output
\`\`\`
$(echo "$CI_BUILD_OUTPUT" | tail -20)
\`\`\`
### Test Output
\`\`\`
$(echo "$CI_TEST_OUTPUT" | tail -20)
\`\`\`
---
*Local CI executed on server*"
CI_COMMENT_JSON=$(jq -n --arg notes "$CI_BODY" '{notes: $notes}')
CI_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
--body "$CI_COMMENT_JSON" 2>&1) || true
if [[ "$(json_ok "$CI_RESULT")" == "true" ]]; then
log_ok "CI results posted"
else
log_warn "CI results post may have failed"
fi
rm -f "$BUILD_LOG" "$TEST_LOG"
# ── Step 7: 冲突检测 + 自动合并 ───────────────────────────────────
MERGED=false
MERGE_RESULT_MSG=""
CONFLICT_DETECTED=false
# Build merge summary: determine why merge can/cannot proceed
MERGE_BLOCK_REASONS=()
if [[ $TOTAL_SCORE -lt $THRESHOLD ]]; then
MERGE_BLOCK_REASONS+=("score $TOTAL_SCORE < threshold $THRESHOLD")
fi
if [[ "$CI_PASSED" != "true" ]]; then
MERGE_BLOCK_REASONS+=("CI not passed")
fi
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
# Cannot merge: log reasons
BLOCK_LIST=$(printf '; %s' "${MERGE_BLOCK_REASONS[@]}")
BLOCK_LIST="${BLOCK_LIST:2}"
log_warn "Merge blocked: $BLOCK_LIST"
else
log_step "Score $TOTAL_SCORE >= $THRESHOLD and CI passed — checking mergeability..."
# Check for merge conflicts
if [[ -d "$REPO_CLONE_DIR/.git" ]]; then
log_info "Checking merge conflicts with base branch..."
(
cd "$REPO_CLONE_DIR"
# Ensure git identity is set for merge test
git config user.email "gatekeeper@gitlink-cli.local" 2>&1 || true
git config user.name "gitlink-gatekeeper" 2>&1 || true
git fetch origin 2>&1 || true
# Determine base branch from PR data
BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.data.pull_request.base // .data.base // "master"')
git checkout "$BASE_BRANCH" 2>&1 || true
git pull origin "$BASE_BRANCH" 2>&1 || true
# Test merge without committing
if git merge --no-commit --no-ff "$PR_HEAD" 2>&1; then
log_ok "No merge conflicts detected"
git merge --abort 2>&1 || true
CONFLICT_DETECTED=false
else
CONFLICT_DETECTED=true
CONFLICT_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "unknown")
log_err "Merge conflict detected in: $CONFLICT_FILES"
git merge --abort 2>&1 || true
fi
git checkout "$PR_HEAD" 2>&1 || true
)
else
log_info "Skipping conflict check (repo not cloned)"
fi
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
MERGE_RESULT_MSG="Merge blocked: conflicts detected"
log_err "$MERGE_RESULT_MSG"
elif [[ "$DRY_RUN" == "true" ]]; then
MERGE_RESULT_MSG="[DRY RUN] Would auto-merge"
log_warn "$MERGE_RESULT_MSG"
else
log_step "Auto-merging PR #$PR_ID..."
MERGE_OUT=$(gl_run pr +merge --owner "$OWNER" --repo "$REPO" --id "$PR_ID" --method merge 2>&1) || true
if [[ "$(json_ok "$MERGE_OUT")" == "true" ]]; then
MERGED=true
MERGE_RESULT_MSG="Merge succeeded"
log_ok "PR #$PR_ID merged successfully!"
else
MERGE_RESULT_MSG="Merge failed: $(json_error "$MERGE_OUT")"
log_err "$MERGE_RESULT_MSG"
fi
fi
fi
# ── Step 8: 发布最终汇总评论 ──────────────────────────────────────
log_step "Posting final summary..."
FINAL_BODY="## Gatekeeper Summary - PR #$PR_ID
| Item | Detail |
|------|--------|
| Score | **$TOTAL_SCORE / 100** (threshold: $THRESHOLD) |
| CI Build | $([[ "$CI_BUILD_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
| CI Test | $([[ "$CI_TEST_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") |
| Merge Conflict | $([[ "$CONFLICT_DETECTED" == "true" ]] && echo "**DETECTED**" || echo "None") |
| Result | $([[ "$MERGED" == "true" ]] && echo "**MERGED**" || echo "$MERGE_RESULT_MSG") |
"
if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then
FINAL_BODY+="
### Blocked Reasons
"
for reason in "${MERGE_BLOCK_REASONS[@]}"; do
FINAL_BODY+="- $reason
"
done
fi
if [[ "$CONFLICT_DETECTED" == "true" ]]; then
FINAL_BODY+="
### Conflict Files
\`\`\`
$CONFLICT_FILES
\`\`\`
"
fi
FINAL_BODY+="
---
*Gatekeeper workflow completed at $(date '+%Y-%m-%d %H:%M:%S')*"
FINAL_JSON=$(jq -n --arg notes "$FINAL_BODY" '{notes: $notes}')
FINAL_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \
--body "$FINAL_JSON" 2>&1) || true
if [[ "$(json_ok "$FINAL_RESULT")" == "true" ]]; then
log_ok "Final summary posted"
else
log_warn "Final summary post may have failed"
fi
# ── Complete ──────────────────────────────────────────────────────
log_title "Gatekeeper Complete"
echo -e "${GREEN} PR: #$PR_ID - $PR_TITLE${NC}"
echo -e "${GREEN} Author: @$PR_AUTHOR${NC}"
echo -e "${GREEN} Score: $TOTAL_SCORE / 100${NC}"
echo -e "${GREEN} CI Passed: $CI_PASSED${NC}"
echo -e "${GREEN} Conflict: $CONFLICT_DETECTED${NC}"
echo -e "${GREEN} Merged: $( [[ "$MERGED" == "true" ]] && echo 'YES' || echo 'NO')${NC}"

View File

@ -1,25 +1,25 @@
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# Scenario 3: One-Click Project Initialization
# Flow: Input description -> Create repo -> README/CONTRIBUTING/CI config ->
# Initial Issues -> Branch protection -> Initial Release
# Flow: Input description -> Create repo -> Generate files (README/LICENSE/
# CONTRIBUTING/CI) -> Push to repo -> Milestones -> Issues ->
# Branch protection -> Initial Release
#
# Commands chained:
# 1. repo +create -- create repository
# 2. wiki +create -- create README wiki page
# 3. wiki +create -- create CONTRIBUTING guide
# 4. wiki +create -- create CI/CD config guide
# 5. issue +create -- create initial issues
# 6. branch +protect -- protect master branch
# 7. release +create -- create initial release
# 2. git clone -- clone the empty repo locally
# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml
# 4. git add/commit/push -- push scaffold files to repo
# 5. milestone +create -- create project milestones
# 6. issue +create -- create initial issues
# 7. branch +protect -- protect default branch
# 8. release +create -- create initial release
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[string]$Owner = "",
[Parameter(Mandatory=$true)]
[string]$Name,
[Parameter(Mandatory=$true)]
[string]$Description,
[string]$Name = "",
[string]$Description = "",
[string]$Lang = "go",
[switch]$Private,
[switch]$DryRun,
@ -27,13 +27,16 @@ param(
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 03-project-init.ps1 -Owner OWNER -Name REPO_NAME -Description DESC [-Lang go|python|node|java] [-Private] [-DryRun]"
exit 0
}
if (-not $Name) { Log-Err "Name is required (use -Help for usage)"; exit 1 }
if (-not $Description) { Log-Err "Description is required (use -Help for usage)"; exit 1 }
Check-Auth
if (-not $Owner) {
$detected = Detect-OwnerRepo
@ -51,180 +54,470 @@ Write-Host " Private: $($Private.IsPresent)"
Divider
# -- Step 1: Create Repository --
Log-Step "Creating repository..."
$privateStr = if ($Private) { "true" } else { "false" }
$repoResult = Invoke-GLCheck "repo", "+create", "--owner", $Owner, "--name", $Name, "--description", $Description, "--private", $privateStr
if ($repoResult) {
Log-Ok "Repository created: $Owner/$Name"
Log-Step "Step 1/8: Creating repository..."
if ($DryRun) {
Log-Warn "[DRY RUN] Would create repository: $Owner/$Name"
} else {
Log-Err "Repository creation failed"
exit 1
$privateStr = if ($Private) { "true" } else { "false" }
$repoResult = Invoke-GLCheck "repo", "+create", "--owner", $Owner, "--name", $Name, "--description", $Description, "--private", $privateStr
if ($repoResult) {
Log-Ok "Repository created: $Owner/$Name"
} else {
Log-Err "Repository creation failed"
exit 1
}
}
# -- Step 2: Create README --
Log-Step "Creating README wiki page..."
Start-Sleep -Seconds 2
# -- Step 2: Clone empty repo --
Log-Step "Step 2/8: Cloning repository..."
$repoUrl = "https://gitlink.org.cn/$Owner/$Name.git"
if ($env:GITLINK_TOKEN) {
$cloneUrl = "https://oauth2:$($env:GITLINK_TOKEN)@gitlink.org.cn/$Owner/$Name.git"
} else {
$cloneUrl = $repoUrl
}
$workDir = Join-Path $env:TEMP "gitlink-init-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))"
$langSection = switch ($Lang) {
"go" {
"### Prerequisites`n- Go 1.21+`n- Git`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`ngo mod download`ngo build ./...`n```````n`n### Usage`n``````bash`ngo run main.go`n```````n`n### Testing`n``````bash`ngo test ./...`n``````"
}
"python" {
"### Prerequisites`n- Python 3.9+`n- pip`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`npip install -r requirements.txt`n```````n`n### Usage`n``````bash`npython main.py`n```````n`n### Testing`n``````bash`npytest`n``````"
}
"node" {
"### Prerequisites`n- Node.js 18+`n- npm or yarn`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`nnpm install`n```````n`n### Usage`n``````bash`nnpm start`n```````n`n### Testing`n``````bash`nnpm test`n``````"
}
"java" {
"### Prerequisites`n- JDK 17+`n- Maven 3.8+`n`n### Installation`n``````bash`ngit clone https://gitlink.org.cn/$Owner/$Name.git`ncd $Name`nmvn clean install`n```````n`n### Usage`n``````bash`nmvn exec:java`n```````n`n### Testing`n``````bash`nmvn test`n``````"
}
default { "" }
if ($DryRun) {
Log-Warn "[DRY RUN] Would clone: $repoUrl -> $workDir"
} else {
git clone $cloneUrl $workDir 2>&1 | Select-Object -Last 1
Log-Ok "Cloned to $workDir"
}
$readmeContent = "# $Name" + "`n`n"
$readmeContent += "$Description" + "`n`n"
$readmeContent += "## Getting Started" + "`n`n"
$readmeContent += $langSection + "`n`n"
$readmeContent += "## Contributing" + "`n`n"
$readmeContent += "See [CONTRIBUTING](./CONTRIBUTING) for guidelines." + "`n`n"
$readmeContent += "## License" + "`n`n"
$readmeContent += "This project is licensed under the MIT License."
# -- Step 3: Generate project files --
Log-Step "Step 3/8: Generating project scaffold files..."
$wikiOk = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
<<<<<<< HEAD
$wikiResult = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"README",--content,$readmeContent
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
=======
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "README", "--content", $readmeContent
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
>>>>>>> master
Log-Ok "README created"
$wikiOk = $true
break
function Get-ReadmeContent {
$langSection = switch ($Lang) {
"go" {
@"
### 环境要求
- Go 1.21+
- Git
### 安装
```bash
git clone https://gitlink.org.cn/$Owner/$Name.git
cd $Name
go mod download
go build ./...
```
### 使用
```bash
go run main.go
```
### 测试
```bash
go test ./...
```
"@
}
"python" {
@"
### 环境要求
- Python 3.9+
- pip
### 安装
```bash
git clone https://gitlink.org.cn/$Owner/$Name.git
cd $Name
pip install -r requirements.txt
```
### 使用
```bash
python main.py
```
### 测试
```bash
pytest
```
"@
}
"node" {
@"
### 环境要求
- Node.js 18+
- npm or yarn
### 安装
```bash
git clone https://gitlink.org.cn/$Owner/$Name.git
cd $Name
npm install
```
### 使用
```bash
npm start
```
### 测试
```bash
npm test
```
"@
}
"java" {
@"
### 环境要求
- JDK 17+
- Maven 3.8+
### 安装
```bash
git clone https://gitlink.org.cn/$Owner/$Name.git
cd $Name
mvn clean install
```
### 使用
```bash
mvn exec:java
```
### 测试
```bash
mvn test
```
"@
}
default { "" }
}
if ($attempt -lt 3) { Start-Sleep -Seconds 2 }
return @"
# $Name
$Description
## 快速开始
$langSection
## 贡献指南
详见 [CONTRIBUTING](./CONTRIBUTING.md)
## 许可证
本项目基于 MIT 许可证开源 详见 [LICENSE](./LICENSE)
"@
}
if (-not $wikiOk) { Log-Warn "README wiki creation may have failed" }
# -- Step 3: Create CONTRIBUTING Guide --
Log-Step "Creating CONTRIBUTING guide..."
function Get-LicenseContent {
$year = (Get-Date).Year
return @"
MIT License
$contribContent = "# Contributing to $Name" + "`n`n"
$contribContent += "Thank you for your interest in contributing!" + "`n`n"
$contribContent += "## How to Contribute" + "`n`n"
$contribContent += "1. Fork the repository" + "`n"
$contribContent += "2. Create a feature branch: ``git checkout -b feature/my-feature```n"
$contribContent += "3. Make your changes" + "`n"
$contribContent += "4. Run tests to ensure everything passes" + "`n"
$contribContent += "5. Commit your changes: ``git commit -m 'feat: add my feature'```n"
$contribContent += "6. Push to your fork: ``git push origin feature/my-feature```n"
$contribContent += "7. Create a Pull Request" + "`n`n"
$contribContent += "## Code Style" + "`n`n"
$contribContent += "- Follow the existing code style" + "`n"
$contribContent += "- Write meaningful commit messages" + "`n"
$contribContent += "- Add tests for new features" + "`n"
$contribContent += "- Update documentation as needed" + "`n`n"
$contribContent += "## Reporting Issues" + "`n`n"
$contribContent += "- Use the issue tracker" + "`n"
$contribContent += "- Include reproduction steps" + "`n"
$contribContent += "- Include environment details"
Copyright (c) $year $Owner
$wikiOk = $false
for ($attempt = 1; $attempt -le 3; $attempt++) {
<<<<<<< HEAD
$wikiContrib = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"CONTRIBUTING",--content,$contribContent
if ($wikiContrib -and (Get-JsonOk ($wikiContrib | ConvertFrom-Json))) {
=======
$wikiContrib = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CONTRIBUTING", "--content", $contribContent
if ($wikiContrib -and (Get-JsonOk $wikiContrib)) {
>>>>>>> master
Log-Ok "CONTRIBUTING guide created"
$wikiOk = $true
break
}
if ($attempt -lt 3) { Start-Sleep -Seconds 2 }
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"@
}
if (-not $wikiOk) { Log-Warn "CONTRIBUTING wiki creation may have failed" }
# -- Step 4: Create CI Config Guide --
Log-Step "Creating CI/CD configuration guide..."
function Get-ContributingContent {
return @"
# Contributing to $Name
$ciContent = "# CI/CD Configuration" + "`n`n"
$ciContent += "## GitLink CI Setup" + "`n`n"
$ciContent += "This project uses GitLink CI for continuous integration." + "`n`n"
$ciContent += "### Pipeline Stages" + "`n`n"
$ciContent += "1. **Test**: Run unit tests" + "`n"
$ciContent += "2. **Build**: Build the project" + "`n"
$ciContent += "3. **Deploy**: Deploy to staging (master branch only)" + "`n`n"
$ciContent += "### Configuration" + "`n`n"
$ciContent += "Create a ``.gitlink-ci.yml`` file in the repository root."
Thank you for your interest in contributing!
<<<<<<< HEAD
Invoke-GL wiki,+create,--owner,$Owner,--repo,$Name,--title,"CI/CD Configuration",--content,$ciContent | Out-Null
=======
Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Name, "--title", "CI/CD Configuration", "--content", $ciContent | Out-Null
>>>>>>> master
Log-Ok "CI/CD configuration guide created"
## How to Contribute
# -- Step 5: Create Initial Issues --
Log-Step "Creating initial issues..."
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make your changes
4. Run tests to ensure everything passes
5. Commit your changes: `git commit -m 'feat: add my feature'`
6. Push to your fork: `git push origin feature/my-feature`
7. Create a Pull Request
$issuesToCreate = @(
@{ Title = "Setup CI/CD Pipeline"; Body = "Configure continuous integration and deployment for the project.`n`n## Tasks`n- [ ] Create .gitlink-ci.yml configuration`n- [ ] Setup test stage`n- [ ] Setup build stage`n- [ ] Setup deploy stage`n- [ ] Add status badge to README"; Label = "feature" },
@{ Title = "Write Project Documentation"; Body = "Complete project documentation including API docs and architecture guide.`n`n## Tasks`n- [ ] Write API documentation`n- [ ] Create architecture diagram`n- [ ] Add usage examples`n- [ ] Document configuration options"; Label = "documentation" },
@{ Title = "Setup Code Review Process"; Body = "Establish code review guidelines and automation.`n`n## Tasks`n- [ ] Define review checklist`n- [ ] Setup branch protection rules`n- [ ] Configure required reviewers`n- [ ] Document review process"; Label = "enhancement" },
@{ Title = "Add Unit Tests"; Body = "Add comprehensive unit test coverage for core modules.`n`n## Tasks`n- [ ] Setup test framework`n- [ ] Write tests for core modules`n- [ ] Achieve 80 percent code coverage`n- [ ] Add CI test integration"; Label = "enhancement" },
@{ Title = "Setup Dependency Management"; Body = "Configure dependency scanning and updates.`n`n## Tasks`n- [ ] Setup dependency scanner`n- [ ] Configure automatic updates`n- [ ] Add license compliance check`n- [ ] Document dependency policy"; Label = "security" }
)
## Code Style
foreach ($entry in $issuesToCreate) {
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Name, "--title", $entry.Title, "--body", $entry.Body
if ($issueResult) {
try {
$issueJson = $issueResult
$issueNum = if ($issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.data.number) { $issueJson.data.number } else { $null }
if ($issueNum) {
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Name, "--number", $issueNum, "--labels", $entry.Label | Out-Null
Log-Ok "Issue created: #$issueNum - $($entry.Title)"
}
} catch {
Log-Warn "Issue creation may have failed: $($entry.Title)"
- Follow the existing code style
- Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/)
- Add tests for new features
- Update documentation as needed
## Reporting Issues
- Use the [issue tracker](https://gitlink.org.cn/$Owner/$Name/issues)
- Include steps to reproduce
- Include environment details (OS, language version, etc.)
## Code of Conduct
Please be respectful and constructive in all interactions.
"@
}
function Get-CIConfigContent {
switch ($Lang) {
"go" {
return @"
image: golang:1.21
stages:
- test
- build
test:
stage: test
script:
- go mod download
- go test ./... -v -cover
build:
stage: build
script:
- go build ./...
"@
}
"python" {
return @"
image: python:3.9
stages:
- test
- lint
test:
stage: test
script:
- pip install -r requirements.txt
- pytest -v --cov
lint:
stage: lint
script:
- pip install flake8
- flake8 .
"@
}
"node" {
return @"
image: node:18
stages:
- test
- build
test:
stage: test
script:
- npm ci
- npm test -- --coverage
build:
stage: build
script:
- npm ci
- npm run build
"@
}
"java" {
return @"
image: maven:3.8-openjdk-17
stages:
- test
- build
test:
stage: test
script:
- mvn test
build:
stage: build
script:
- mvn package -DskipTests
"@
}
default {
return "# TODO: configure CI/CD pipeline for $Lang"
}
}
}
# -- Step 6: Protect Default Branch --
Log-Step "Protecting master branch..."
$protectResult = Invoke-GL "branch", "+protect", "--owner", $Owner, "--repo", $Name, "--name", "master"
if ($protectResult -and (Get-JsonOk $protectResult)) {
Log-Ok "Branch 'master' protected"
if ($DryRun) {
Log-Warn "[DRY RUN] Would generate: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
} else {
Log-Warn "Branch protection may have failed (may require admin permissions)"
$utf8 = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText("$workDir\README.md", (Get-ReadmeContent), $utf8)
[System.IO.File]::WriteAllText("$workDir\LICENSE", (Get-LicenseContent), $utf8)
[System.IO.File]::WriteAllText("$workDir\CONTRIBUTING.md", (Get-ContributingContent), $utf8)
[System.IO.File]::WriteAllText("$workDir\.gitlink-ci.yml", (Get-CIConfigContent), $utf8)
Log-Ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
}
# -- Step 7: Create Initial Release --
Log-Step "Creating initial release v0.1.0..."
$releaseBody = "# v0.1.0 - Initial Release" + "`n`n"
$releaseBody += "## What's New" + "`n"
$releaseBody += "- Project initialized with $Lang template" + "`n"
$releaseBody += "- README and CONTRIBUTING guides created" + "`n"
$releaseBody += "- CI/CD configuration guide created" + "`n"
$releaseBody += "- 5 initial issues filed" + "`n"
$releaseBody += "- Branch protection enabled" + "`n`n"
$releaseBody += "## Next Steps" + "`n"
$releaseBody += "- [ ] Setup CI/CD pipeline" + "`n"
$releaseBody += "- [ ] Write comprehensive tests" + "`n"
$releaseBody += "- [ ] Complete documentation" + "`n"
$releaseBody += "- [ ] First feature implementation" + "`n`n"
$releaseBody += "---`n*Auto-initialized by gitlink-cli project-init workflow*"
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Name, "--tag", "v0.1.0", "--name", "Initial Release", "--body", $releaseBody
if ($releaseResult -and (Get-JsonOk $releaseResult)) {
Log-Ok "Release v0.1.0 created"
# -- Step 4: Push files to repo --
Log-Step "Step 4/8: Pushing scaffold files to repository..."
if ($DryRun) {
Log-Warn "[DRY RUN] Would git add/commit/push to $repoUrl"
} else {
Log-Warn "Release creation may have failed"
Push-Location $workDir
try {
$commitMsg = "chore: initialize project scaffold`n`n- Add README.md with getting-started guide`n- Add MIT LICENSE`n- Add CONTRIBUTING.md with guidelines`n- Add .gitlink-ci.yml CI pipeline configuration"
git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml 2>&1 | Out-Null
git -c user.name="$Owner" -c user.email="$Owner@gitlink.org.cn" commit -m $commitMsg 2>&1 | Select-Object -Last 1
git push -u origin master 2>&1 | Select-Object -Last 1
Log-Ok "Scaffold files pushed to master"
} finally {
Pop-Location
}
}
# -- Step 5: Create Milestones --
Log-Step "Step 5/8: Creating initial milestone..."
$milestones = @(
@{ Name = "v1.0.0 - First Release"; Description = "First official release milestone" }
)
if ($DryRun) {
Log-Warn "[DRY RUN] Would create $($milestones.Count) milestones"
} else {
foreach ($ms in $milestones) {
$msResult = Invoke-GL "milestone", "+create", "--owner", $Owner, "--repo", $Name, "--name", $ms.Name, "--description", $ms.Description
if ($msResult) {
try {
$msJson = $msResult | ConvertFrom-Json
if ($msJson.ok) {
Log-Ok "Milestone created: $($ms.Name)"
} else {
Log-Warn "Milestone creation failed: $($ms.Name)"
}
} catch {
Log-Warn "Milestone creation may have failed: $($ms.Name)"
}
}
}
}
# -- Step 6: Create Initial Issues --
Log-Step "Step 6/8: Creating welcome issue..."
$issuesToCreate = @(
@{ Title = "Welcome to $Name"; Body = "欢迎来到 **${Name}** 的仓库这是本仓库的第一条issue"; Label = "welcome" }
)
if ($DryRun) {
Log-Warn "[DRY RUN] Would create $($issuesToCreate.Count) initial issues"
} else {
foreach ($entry in $issuesToCreate) {
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Name, "--title", $entry.Title, "--body", $entry.Body
if ($issueResult) {
try {
$issueJson = $issueResult | ConvertFrom-Json
$issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null }
if ($issueNum) {
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Name, "--number", $issueNum, "--labels", $entry.Label | Out-Null
Log-Ok "Issue created: #$issueNum - $($entry.Title)"
}
} catch {
Log-Warn "Issue creation may have failed: $($entry.Title)"
}
}
}
}
# -- Step 7: Protect Default Branch --
Log-Step "Step 7/8: Protecting master branch..."
if ($DryRun) {
Log-Warn "[DRY RUN] Would protect branch 'master'"
} else {
$protectResult = Invoke-GL "branch", "+protect", "--owner", $Owner, "--repo", $Name, "--name", "master"
if ($protectResult) {
try {
$protectJson = $protectResult | ConvertFrom-Json
if ($protectJson.ok) {
Log-Ok "Branch 'master' protected"
} else {
Log-Warn "Branch protection may have failed (may require admin permissions)"
}
} catch {
Log-Warn "Branch protection may have failed (may require admin permissions)"
}
}
}
# -- Step 8: Create Initial Release --
Log-Step "Step 8/8: Creating initial release v0.1.0..."
$releaseBody = @"
# v0.1.0 - Initial Release
## What's New
- Project initialized with $Lang template
- README, LICENSE, CONTRIBUTING guide in repository
- CI/CD pipeline configuration (`.gitlink-ci.yml`)
- $($milestones.Count) project milestone created
- $($issuesToCreate.Count) initial issues filed
- Branch protection enabled
## Next Steps
- [ ] Setup CI/CD pipeline
- [ ] Write comprehensive tests
- [ ] Complete documentation
- [ ] First feature implementation
---
*Auto-initialized by gitlink-cli project-init workflow*
"@
if ($DryRun) {
Log-Warn "[DRY RUN] Would create release v0.1.0"
} else {
$releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Name, "--tag", "v0.1.0", "--name", "Initial Release", "--body", $releaseBody
if ($releaseResult) {
try {
$releaseJson = $releaseResult | ConvertFrom-Json
if ($releaseJson.ok) {
Log-Ok "Release v0.1.0 created"
} else {
Log-Warn "Release creation may have failed"
}
} catch {
Log-Warn "Release creation may have failed"
}
}
}
# ----------------------------------------------------------------
@ -232,14 +525,18 @@ Log-Title "Project Initialization Complete"
# ----------------------------------------------------------------
Write-Host " Repository: $Owner/$Name" -ForegroundColor Green
Write-Host " README: Wiki page" -ForegroundColor Green
Write-Host " CONTRIBUTING: Wiki page" -ForegroundColor Green
Write-Host " CI/CD Guide: Wiki page" -ForegroundColor Green
Write-Host " Issues: 5 initial issues" -ForegroundColor Green
Write-Host " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" -ForegroundColor Green
Write-Host " Milestones: $($milestones.Count) milestone" -ForegroundColor Green
Write-Host " Issues: $($issuesToCreate.Count) initial issues" -ForegroundColor Green
Write-Host " Branch: master (protected)" -ForegroundColor Green
Write-Host " Release: v0.1.0" -ForegroundColor Green
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Cyan
Write-Host " 1. Clone: git clone https://gitlink.org.cn/$Owner/$Name.git"
Write-Host " 2. Add your code and push"
Write-Host " 3. Setup CI/CD by closing the first issue"
Write-Host " 1. Clone: git clone $repoUrl"
Write-Host " 2. Start coding and close the first issue"
Write-Host " 3. CI/CD will trigger on your first push"
# Cleanup temp directory
if (-not $DryRun -and (Test-Path $workDir)) {
Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue
}

View File

@ -1,15 +1,19 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────
# Scenario 3: One-Click Project Initialization
# Flow: Input description → Create repo → README/LICENSE/CI → Issues → Release
# Flow: Input description → Create repo → Generate files (README/LICENSE/
# CONTRIBUTING/CI) → Push to repo → Milestones → Issues →
# Branch protection → Initial Release
#
# Commands/Skills chained:
# Commands chained:
# 1. repo +create -- create repository
# 2. wiki +create -- create README wiki page
# 3. wiki +create -- create CONTRIBUTING guide
# 4. issue +create -- create initial issues
# 5. branch +protect -- protect default branch
# 6. release +create -- create initial release
# 2. git clone -- clone the empty repo locally
# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml
# 4. git add/commit/push -- push scaffold files to repo
# 5. milestone +create -- create project milestones
# 6. issue +create -- create initial issues
# 7. branch +protect -- protect default branch
# 8. release +create -- create initial release
# ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -65,33 +69,49 @@ echo " Private: $PRIVATE"
divider
# ── Step 1: Create Repository ────────────────────────────────────────
log_step "Creating repository..."
REPO_RESULT=$(gl_check repo +create --owner "$OWNER" --name "$REPO_NAME" --description "$DESCRIPTION" --private "$PRIVATE")
REPO_ID=$(echo "$REPO_RESULT" | jq -r '.data.id // .data.project_id // empty')
log_ok "Repository created: $OWNER/$REPO_NAME (id: $REPO_ID)"
log_step "Step 1/8: Creating repository..."
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would create repository: $OWNER/$REPO_NAME"
else
REPO_RESULT=$(gl_check repo +create --owner "$OWNER" --name "$REPO_NAME" --description "$DESCRIPTION" --private "$PRIVATE")
REPO_ID=$(echo "$REPO_RESULT" | jq -r '.data.id // .data.project_id // empty')
log_ok "Repository created: $OWNER/$REPO_NAME (id: $REPO_ID)"
fi
# ── Step 2: Create README ────────────────────────────────────────────
log_step "Creating README wiki page..."
# ── Step 2: Clone empty repo ─────────────────────────────────────────
log_step "Step 2/8: Cloning repository..."
# Build auth URL using token so clone/push don't prompt for password
if [[ -n "${GITLINK_TOKEN:-}" ]]; then
CLONE_URL="https://oauth2:${GITLINK_TOKEN}@gitlink.org.cn/$OWNER/$REPO_NAME.git"
else
CLONE_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
fi
REPO_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
WORK_DIR=$(mktemp -d /tmp/gitlink-init-XXXXXX)
cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT
# Wait for repo to be fully initialized
sleep 2
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would clone: $REPO_URL$WORK_DIR"
else
git clone "$CLONE_URL" "$WORK_DIR" 2>&1 | tail -1 || true
log_ok "Cloned to $WORK_DIR"
fi
README_CONTENT="# $REPO_NAME
# ── Step 3: Generate project files ───────────────────────────────────
log_step "Step 3/8: Generating project scaffold files..."
$DESCRIPTION
## Getting Started
### Prerequisites"
case "$PROJ_LANG" in
go)
README_CONTENT+="
# --- README.md ---
gen_readme() {
local lang_section
case "$PROJ_LANG" in
go)
lang_section="### 环境要求
- Go 1.21+
- Git
### Installation
### 安装
\`\`\`bash
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
@ -100,25 +120,25 @@ go mod download
go build ./...
\`\`\`
### Usage
### 使用
\`\`\`bash
go run main.go
\`\`\`
### Testing
### 测试
\`\`\`bash
go test ./...
\`\`\`"
;;
python)
README_CONTENT+="
;;
python)
lang_section="### 环境要求
- Python 3.9+
- pip
### Installation
### 安装
\`\`\`bash
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
@ -126,25 +146,25 @@ cd $REPO_NAME
pip install -r requirements.txt
\`\`\`
### Usage
### 使用
\`\`\`bash
python main.py
\`\`\`
### Testing
### 测试
\`\`\`bash
pytest
\`\`\`"
;;
node)
README_CONTENT+="
;;
node)
lang_section="### 环境要求
- Node.js 18+
- npm or yarn
### Installation
### 安装
\`\`\`bash
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
@ -152,25 +172,25 @@ cd $REPO_NAME
npm install
\`\`\`
### Usage
### 使用
\`\`\`bash
npm start
\`\`\`
### Testing
### 测试
\`\`\`bash
npm test
\`\`\`"
;;
java)
README_CONTENT+="
;;
java)
lang_section="### 环境要求
- JDK 17+
- Maven 3.8+
### Installation
### 安装
\`\`\`bash
git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git
@ -178,48 +198,72 @@ cd $REPO_NAME
mvn clean install
\`\`\`
### Usage
### 使用
\`\`\`bash
mvn exec:java
\`\`\`
### Testing
### 测试
\`\`\`bash
mvn test
\`\`\`"
;;
esac
;;
esac
README_CONTENT+="
cat <<READMEEOF
# $REPO_NAME
## Contributing
$DESCRIPTION
See [CONTRIBUTING](./CONTRIBUTING) for guidelines.
## 快速开始
## License
$lang_section
This project is licensed under the MIT License."
## 贡献指南
# Retry wiki creation up to 3 times
WIKI_OK=false
for attempt in 1 2 3; do
WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO_NAME" \
--title "README" --content "$README_CONTENT" 2>&1) || true
if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then
log_ok "README created"
WIKI_OK=true
break
fi
[[ $attempt -lt 3 ]] && sleep 2
done
[[ "$WIKI_OK" == "false" ]] && log_warn "README wiki creation may have failed"
详见 [CONTRIBUTING](./CONTRIBUTING.md)
# ── Step 3: Create CONTRIBUTING Guide ────────────────────────────────
log_step "Creating CONTRIBUTING guide..."
## 许可证
CONTRIB_CONTENT="# Contributing to $REPO_NAME
本项目基于 MIT 许可证开源 — 详见 [LICENSE](./LICENSE)
READMEEOF
}
# --- LICENSE (MIT) ---
gen_license() {
local year
year=$(date +%Y)
cat <<LICENSEEOF
MIT License
Copyright (c) $year $OWNER
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
LICENSEEOF
}
# --- CONTRIBUTING.md ---
gen_contributing() {
cat <<CONTRIBEOF
# Contributing to $REPO_NAME
Thank you for your interest in contributing!
@ -236,78 +280,208 @@ Thank you for your interest in contributing!
## Code Style
- Follow the existing code style
- Write meaningful commit messages
- Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/)
- Add tests for new features
- Update documentation as needed
## Reporting Issues
- Use the issue tracker
- Include reproduction steps
- Include environment details
- Use the [issue tracker](https://gitlink.org.cn/$OWNER/$REPO_NAME/issues)
- Include steps to reproduce
- Include environment details (OS, language version, etc.)
## Code of Conduct
Please be respectful and constructive in all interactions."
Please be respectful and constructive in all interactions.
CONTRIBEOF
}
# Retry wiki creation up to 3 times
WIKI_OK=false
for attempt in 1 2 3; do
WIKI_CONTRIB=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO_NAME" \
--title "CONTRIBUTING" --content "$CONTRIB_CONTENT" 2>&1) || true
if [[ "$(json_ok "$WIKI_CONTRIB")" == "true" ]]; then
log_ok "CONTRIBUTING guide created"
WIKI_OK=true
break
fi
[[ $attempt -lt 3 ]] && sleep 2
done
[[ "$WIKI_OK" == "false" ]] && log_warn "CONTRIBUTING wiki creation may have failed"
# --- .gitlink-ci.yml ---
gen_ci_config() {
case "$PROJ_LANG" in
go)
cat <<CIEOF
image: golang:1.21
# ── Step 4: Create Initial Issues ────────────────────────────────────
log_step "Creating initial issues..."
stages:
- test
- build
ISSUES_TO_CREATE=(
"Setup CI/CD Pipeline|Configure continuous integration and deployment for the project.|feature"
"Write Project Documentation|Complete project documentation including API docs and architecture guide.|documentation"
"Setup Code Review Process|Establish code review guidelines and automation.|enhancement"
"Add Unit Tests|Add comprehensive unit test coverage for core modules.|enhancement"
"Setup Dependency Management|Configure dependency scanning and updates.|security"
)
test:
stage: test
script:
- go mod download
- go test ./... -v -cover
for entry in "${ISSUES_TO_CREATE[@]}"; do
IFS='|' read -r title body label <<< "$entry"
ISSUE_RESULT=$(gl_run issue +create --owner "$OWNER" --repo "$REPO_NAME" \
--title "$title" --body "$body" 2>&1) || true
ISSUE_NUM=$(echo "$ISSUE_RESULT" | jq -r '.data.id // .data.number // empty')
if [[ -n "$ISSUE_NUM" ]]; then
# Add label
gl_run issue +label-add --owner "$OWNER" --repo "$REPO_NAME" --number "$ISSUE_NUM" --labels "$label" > /dev/null 2>&1 || true
log_ok "Issue created: #$ISSUE_NUM - $title"
else
log_warn "Issue creation may have failed: $title"
fi
done
build:
stage: build
script:
- go build ./...
CIEOF
;;
python)
cat <<CIEOF
image: python:3.9
# ── Step 5: Protect Default Branch ───────────────────────────────────
log_step "Protecting master branch..."
PROTECT_RESULT=$(gl_run branch +protect --owner "$OWNER" --repo "$REPO_NAME" --name master 2>&1) || true
stages:
- test
- lint
if [[ "$(json_ok "$PROTECT_RESULT")" == "true" ]]; then
log_ok "Branch 'master' protected"
test:
stage: test
script:
- pip install -r requirements.txt
- pytest -v --cov
lint:
stage: lint
script:
- pip install flake8
- flake8 .
CIEOF
;;
node)
cat <<CIEOF
image: node:18
stages:
- test
- build
test:
stage: test
script:
- npm ci
- npm test -- --coverage
build:
stage: build
script:
- npm ci
- npm run build
CIEOF
;;
java)
cat <<CIEOF
image: maven:3.8-openjdk-17
stages:
- test
- build
test:
stage: test
script:
- mvn test
build:
stage: build
script:
- mvn package -DskipTests
CIEOF
;;
esac
}
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would generate: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
else
log_warn "Branch protection may have failed (may require admin permissions)"
gen_readme > "$WORK_DIR/README.md"
gen_license > "$WORK_DIR/LICENSE"
gen_contributing > "$WORK_DIR/CONTRIBUTING.md"
gen_ci_config > "$WORK_DIR/.gitlink-ci.yml"
log_ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
fi
# ── Step 6: Create Initial Release ───────────────────────────────────
log_step "Creating initial release v0.1.0..."
# ── Step 4: Push files to repo ───────────────────────────────────────
log_step "Step 4/8: Pushing scaffold files to repository..."
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would git add/commit/push to $REPO_URL"
else
pushd "$WORK_DIR" > /dev/null
git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml
git -c user.name="$OWNER" -c user.email="$OWNER@gitlink.org.cn" \
commit -m "chore: initialize project scaffold
- Add README.md with getting-started guide
- Add MIT LICENSE
- Add CONTRIBUTING.md with guidelines
- Add .gitlink-ci.yml CI pipeline configuration" 2>&1 | tail -1
git push -u origin master 2>&1 | tail -1
popd > /dev/null
log_ok "Scaffold files pushed to master"
fi
# ── Step 5: Create Milestone ─────────────────────────────────────────
log_step "Step 5/8: Creating initial milestone..."
MILESTONES=(
"v1.0.0 - First Release|First official release milestone"
)
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would create ${#MILESTONES[@]} milestone"
else
for entry in "${MILESTONES[@]}"; do
IFS='|' read -r ms_title ms_desc <<< "$entry"
MS_RESULT=$(gl_run milestone +create --owner "$OWNER" --repo "$REPO_NAME" \
--name "$ms_title" --description "$ms_desc" 2>&1) || true
if [[ "$(json_ok "$MS_RESULT")" == "true" ]]; then
log_ok "Milestone created: $ms_title"
else
log_warn "Milestone creation may have failed: $ms_title"
fi
done
fi
# ── Step 6: Create Welcome Issue ─────────────────────────────────────
log_step "Step 6/8: Creating welcome issue..."
ISSUES_TO_CREATE=(
"Welcome to $REPO_NAME|欢迎来到 **${REPO_NAME}** 的仓库这是本仓库的第一条issue|welcome"
)
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would create ${#ISSUES_TO_CREATE[@]} initial issues"
else
for entry in "${ISSUES_TO_CREATE[@]}"; do
IFS='|' read -r title body label <<< "$entry"
ISSUE_RESULT=$(gl_run issue +create --owner "$OWNER" --repo "$REPO_NAME" \
--title "$title" --body "$body" 2>&1) || true
ISSUE_NUM=$(echo "$ISSUE_RESULT" | jq -r '.data.id // .data.number // empty')
if [[ -n "$ISSUE_NUM" ]]; then
gl_run issue +label-add --owner "$OWNER" --repo "$REPO_NAME" --number "$ISSUE_NUM" --labels "$label" > /dev/null 2>&1 || true
log_ok "Issue created: #$ISSUE_NUM - $title"
else
log_warn "Issue creation may have failed: $title"
fi
done
fi
# ── Step 7: Protect Default Branch ───────────────────────────────────
log_step "Step 7/8: Protecting master branch..."
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would protect branch 'master'"
else
PROTECT_RESULT=$(gl_run branch +protect --owner "$OWNER" --repo "$REPO_NAME" --name master 2>&1) || true
if [[ "$(json_ok "$PROTECT_RESULT")" == "true" ]]; then
log_ok "Branch 'master' protected"
else
log_warn "Branch protection may have failed (may require admin permissions)"
fi
fi
# ── Step 8: Create Initial Release ───────────────────────────────────
log_step "Step 8/8: Creating initial release v0.1.0..."
RELEASE_BODY="# v0.1.0 - Initial Release
## What's New
- Project initialized with $PROJ_LANG template
- README and CONTRIBUTING guides created
- CI/CD pipeline issues filed
- README, LICENSE, CONTRIBUTING guide in repository
- CI/CD pipeline configuration (.gitlink-ci.yml)
- ${#MILESTONES[@]} project milestone created
- ${#ISSUES_TO_CREATE[@]} initial issues filed
- Branch protection enabled
## Next Steps
@ -319,13 +493,16 @@ RELEASE_BODY="# v0.1.0 - Initial Release
---
*Auto-initialized by gitlink-cli project-init workflow*"
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO_NAME" \
--tag "v0.1.0" --name "Initial Release" --body "$RELEASE_BODY" 2>&1) || true
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release v0.1.0 created"
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] Would create release v0.1.0"
else
log_warn "Release creation may have failed"
RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO_NAME" \
--tag "v0.1.0" --name "Initial Release" --body "$RELEASE_BODY" 2>&1) || true
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release v0.1.0 created"
else
log_warn "Release creation may have failed"
fi
fi
# ─────────────────────────────────────────────────────────────────────
@ -334,14 +511,14 @@ log_title "Project Initialization Complete"
echo -e "${GREEN}Created:${NC}"
echo " Repository: $OWNER/$REPO_NAME"
echo " README: Wiki page"
echo " CONTRIBUTING: Wiki page"
echo " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml"
echo " Milestones: ${#MILESTONES[@]} milestone"
echo " Issues: ${#ISSUES_TO_CREATE[@]} initial issues"
echo " Branch: master (protected)"
echo " Release: v0.1.0"
echo ""
echo -e "${CYAN}Next steps:${NC}"
echo " 1. Clone: git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git"
echo " 2. Add your code and push"
echo " 3. Setup CI/CD by closing the first issue"
echo " 1. Clone: git clone $REPO_URL"
echo " 2. Start coding and close the first issue"
echo " 3. CI/CD will trigger on your first push"
echo ""

View File

@ -1,55 +1,99 @@
# ----------------------------------------------------------------
# ----------------------------------------------------------------
# Scenario 4: Multi-Repo Collaboration
# Flow: Cross-repo issue tracking -> PR status dashboard -> Coordinated release
#
# Commands chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch issues from each repo
# 3. pr +list -- fetch PRs from each repo
# 4. release +list -- check release status across repos
# 5. release +create -- coordinated release (optional)
# 6. Generate HTML dashboard
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch open/closed issues from each repo
# 3. pr +list -- fetch open/merged PRs from each repo
# 4. release +list -- check latest release across repos
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
# 6. release +create -- (optional) coordinated release across all repos
# ----------------------------------------------------------------
#Requires -Version 5.1
param(
[Parameter(Mandatory=$true)]
[string]$Org,
[string]$Org = "",
[string]$Repos = "",
[string]$Release = "",
[string]$Output = "dashboard.html",
[int]$DetailLimit = 10,
[switch]$DryRun,
[switch]$Help
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DryRun]"
Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DetailLimit N] [-DryRun]"
exit 0
}
if (-not $Org) { Log-Err "Org is required (use -Help for usage)"; exit 1 }
Check-Auth
# -- Step 1: List Repositories --
Log-Title "Multi-Repo Collaboration Dashboard"
# -- Helpers --
function Html-Escape([string]$s) {
if ($null -eq $s) { return "" }
$s = $s -replace '&', '&amp;'
$s = $s -replace '<', '&lt;'
$s = $s -replace '>', '&gt;'
$s = $s -replace '"', '&quot;'
return $s
}
Log-Step "Fetching repositories for org: $Org..."
# Health score (0-100) aligned with gitlink-health skill.
# Core 75 (issue efficiency / PR efficiency / activity, -25 each),
# auxiliary 25 (issue backlog -10, PR backlog -10, no recent release -5).
# releaseAge: days since latest release, -1 = none.
function Compute-Health([int]$oi, [int]$ci, [int]$op, [int]$mp, [int]$age) {
$health = 100
# 核心指标 (75)
$ti = $oi + $ci
$res = if ($ti -gt 0) { $ci / $ti } else { 1 }
if ($res -lt 0.5) { $health -= 25 } # Issue 处理效率 (响应慢代理)
$tp = $op + $mp
$mr = if ($tp -gt 0) { $mp / $tp } else { 1 }
if ($mr -lt 0.5) { $health -= 25 } # PR 合并效率 (合并慢代理)
if (($mp + $ci) -eq 0) { $health -= 25 } # 活跃度低: 无已完成工作
# 辅助指标 (25)
if ($oi -gt 20) { $health -= 10 } # Issue 积压
if ($op -gt 10) { $health -= 10 } # PR 积压
if ($age -lt 0 -or $age -gt 30) { $health -= 5 } # 无近期发布
if ($health -lt 0) { $health = 0 }
if ($health -gt 100) { $health = 100 }
return [int]$health
}
# Map score -> @{ Label; Class } (5 bands per gitlink-health skill)
function Get-HealthGrade([int]$score) {
if ($score -ge 90) { return @{ Label = "优秀"; Class = "excellent" } }
elseif ($score -ge 70) { return @{ Label = "良好"; Class = "good" } }
elseif ($score -ge 50) { return @{ Label = "一般"; Class = "moderate" } }
elseif ($score -ge 30) { return @{ Label = "需关注"; Class = "attention" } }
else { return @{ Label = "严重"; Class = "critical" } }
}
# -- Step 1: List Repositories --
Log-Title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
Log-Step "获取组织 $Org 下的仓库列表..."
$reposJson = Invoke-GLCheck "repo", "+list", "--user", $Org, "--limit", "100"
if (-not $reposJson) { Log-Err "Failed to fetch repos"; exit 1 }
if (-not $reposJson) { Log-Err "获取仓库列表失败"; exit 1 }
$allRepos = @()
$rd = $reposJson.data
if ($rd.projects) { $allRepos = @($rd.projects) }
elseif ($rd -is [array]) { $allRepos = $rd }
Log-Ok "Found $($allRepos.Count) repositories"
Log-Ok "组织下共有 $($allRepos.Count) 个仓库"
$repoList = @()
if ($Repos) {
$repoList = $Repos -split ','
Log-Info "Filtering to specified repos: $($repoList -join ', ')"
Log-Info "仅处理指定仓库: $($repoList -join ', ')"
} else {
foreach ($r in $allRepos) {
$rname = if ($r.name) { $r.name } elseif ($r.identifier) { $r.identifier } else { $null }
@ -57,153 +101,259 @@ if ($Repos) {
}
}
Log-Ok "Will process $($repoList.Count) repositories"
if ($repoList.Count -eq 0) { Log-Err "没有可处理的仓库。请检查 -Org 或用 -Repos 指定。"; exit 1 }
Log-Ok "将处理 $($repoList.Count) 个仓库"
# -- Step 2-3: Collect Issues and PRs from each repo --
Log-Title "Collecting Data Across Repos"
# -- Step 2-4: Collect data across repos --
Log-Title "跨仓库数据采集"
$totalIssues = 0; $totalOpenIssues = 0; $totalPRs = 0; $totalOpenPRs = 0
$totalActivity = 0; $totalOpenIssues = 0; $totalOpenPRs = 0; $totalMergedPRs = 0; $healthSum = 0
$dashboardRows = ""
$repoDetails = ""
foreach ($repo in $repoList) {
Divider
Log-Step "Processing $Org/$repo..."
Log-Step "处理 $Org/$repo..."
$issuesJson = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
$openIssues = if ($issuesJson) { @($issuesJson.data.issues).Count } else { 0 }
# Open issues
$issuesObj = $null; $openIssues = 0; $openIssueItems = @()
$issuesRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
if ($issuesRaw) { try {
$issuesObj = $issuesRaw | ConvertFrom-Json
if ($issuesObj.ok -and $issuesObj.data.issues) { $openIssueItems = @($issuesObj.data.issues); $openIssues = $openIssueItems.Count }
} catch {} }
$closedJson = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
$closedIssues = if ($closedJson) { @($closedJson.data.issues).Count } else { 0 }
# Closed issues
$closedIssues = 0
$closedRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50"
if ($closedRaw) { try {
$closedObj = $closedRaw | ConvertFrom-Json
if ($closedObj.ok -and $closedObj.data.issues) { $closedIssues = @($closedObj.data.issues).Count }
} catch {} }
$prsJson = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
$openPRs = 0
if ($prsJson) {
$pd = $prsJson.data
if ($pd.issues) { $openPRs = @($pd.issues).Count }
elseif ($pd.pulls) { $openPRs = @($pd.pulls).Count }
elseif ($pd -is [array]) { $openPRs = $pd.Count }
}
$mergedJson = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
$mergedPRs = 0
if ($mergedJson) {
$md = $mergedJson.data
if ($md.issues) { $mergedPRs = @($md.issues).Count }
elseif ($md.pulls) { $mergedPRs = @($md.pulls).Count }
elseif ($md -is [array]) { $mergedPRs = $md.Count }
}
$releaseJson = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1"
$latestRelease = "none"
if ($releaseJson -and $releaseJson.data.releases) {
$releases = @($releaseJson.data.releases)
if ($releases.Count -gt 0) {
$latestRelease = if ($releases[0].tag_name) { $releases[0].tag_name } elseif ($releases[0].name) { $releases[0].name } else { "none" }
# Open PRs
$openPRs = 0; $openPRItems = @()
$prsRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50"
if ($prsRaw) { try {
$prsObj = $prsRaw | ConvertFrom-Json
if ($prsObj.ok) {
if ($prsObj.data.issues) { $openPRItems = @($prsObj.data.issues) }
elseif ($prsObj.data.pulls) { $openPRItems = @($prsObj.data.pulls) }
elseif ($prsObj.data -is [array]) { $openPRItems = @($prsObj.data) }
$openPRs = $openPRItems.Count
}
} catch {} }
# Merged PRs
$mergedPRs = 0
$mergedRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50"
if ($mergedRaw) { try {
$mergedObj = $mergedRaw | ConvertFrom-Json
if ($mergedObj.ok) {
if ($mergedObj.data.issues) { $mergedPRs = @($mergedObj.data.issues).Count }
elseif ($mergedObj.data.pulls) { $mergedPRs = @($mergedObj.data.pulls).Count }
elseif ($mergedObj.data -is [array]) { $mergedPRs = @($mergedObj.data).Count }
}
} catch {} }
# Latest release + age
$latestRelease = "none"; $releaseAge = -1
$releaseRaw = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1"
if ($releaseRaw) { try {
$releaseObj = $releaseRaw | ConvertFrom-Json
$rels = $null
if ($releaseObj.ok -and $releaseObj.data.releases) { $rels = @($releaseObj.data.releases) }
elseif ($releaseObj.ok -and $releaseObj.data -is [array]) { $rels = @($releaseObj.data) }
if ($rels -and $rels.Count -gt 0) {
$latestRelease = if ($rels[0].tag_name) { $rels[0].tag_name } elseif ($rels[0].name) { $rels[0].name } else { "none" }
$relDate = if ($rels[0].created_at) { $rels[0].created_at } elseif ($rels[0].created_on) { $rels[0].created_on } elseif ($rels[0].published_at) { $rels[0].published_at } else { $null }
if ($relDate) {
try { $releaseAge = [int]((Get-Date) - [datetime]$relDate).TotalDays } catch { $releaseAge = 0 }
} else { $releaseAge = 0 }
}
} catch {} }
$health = Compute-Health $openIssues $closedIssues $openPRs $mergedPRs $releaseAge
$grade = Get-HealthGrade $health
Log-Ok "$repo : Issue(开:$openIssues 闭:$closedIssues) PR(开:$openPRs 合:$mergedPRs) Release:$latestRelease 健康度:$health($($grade.Label))"
# Open issue detail rows
$issueRows = ""
$limit = [Math]::Min($openIssues, $DetailLimit)
for ($i = 0; $i -lt $limit; $i++) {
$it = $openIssueItems[$i]
$iid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "" }
$ititle = if ($it.name) { $it.name } elseif ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "" }
$iauthor = if ($it.author_name) { $it.author_name } elseif ($it.author_login) { $it.author_login } elseif ($it.author.login) { $it.author.login } else { "unknown" }
$issueRows += "<tr><td>#$(Html-Escape ([string]$iid))</td><td>$(Html-Escape ([string]$ititle))</td><td>@$(Html-Escape ([string]$iauthor))</td></tr>"
}
if (-not $issueRows) { $issueRows = '<tr><td colspan="3" class="empty">无未关闭 Issue</td></tr>' }
Log-Ok "$repo : Issues(open:$openIssues closed:$closedIssues) PRs(open:$openPRs merged:$mergedPRs) Release:$latestRelease"
# Open PR detail rows
$prRows = ""
$limit = [Math]::Min($openPRs, $DetailLimit)
for ($i = 0; $i -lt $limit; $i++) {
$pt = $openPRItems[$i]
$pid = if ($pt.pull_request_number) { $pt.pull_request_number } elseif ($pt.number) { $pt.number } elseif ($pt.id) { $pt.id } else { "" }
$ptitle = if ($pt.subject) { $pt.subject } elseif ($pt.title) { $pt.title } elseif ($pt.name) { $pt.name } else { "" }
$pauthor = if ($pt.author_name) { $pt.author_name } elseif ($pt.author_login) { $pt.author_login } elseif ($pt.author.login) { $pt.author.login } else { "unknown" }
$prRows += "<tr><td>#$(Html-Escape ([string]$pid))</td><td>$(Html-Escape ([string]$ptitle))</td><td>@$(Html-Escape ([string]$pauthor))</td></tr>"
}
if (-not $prRows) { $prRows = '<tr><td colspan="3" class="empty">无未合并 PR</td></tr>' }
$statusColor = "green"
$healthText = "Healthy"
if ($openIssues -gt 10) { $statusColor = "orange"; $healthText = "Moderate" }
if ($openIssues -gt 20) { $statusColor = "red"; $healthText = "Needs Attention" }
$repoDetails += @"
<details><summary><span class="badge $($grade.Class)">$health</span> $(Html-Escape $repo) Issue $openIssues / PR $openPRs</summary>
<div class="detail-grid">
<div><h4>未关闭 Issue最多 $DetailLimit </h4>
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$issueRows</tbody></table></div>
<div><h4>未合并 PR最多 $DetailLimit </h4>
<table class="detail-table"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$prRows</tbody></table></div>
</div>
</details>
"@
$dashboardRows += "<tr>`n"
$dashboardRows += " <td><a href=`"https://gitlink.org.cn/$Org/$repo`">$repo</a></td>`n"
$dashboardRows += " <td>$openIssues</td><td>$closedIssues</td>`n"
$dashboardRows += " <td>$openPRs</td><td>$mergedPRs</td><td>$latestRelease</td>`n"
$dashboardRows += " <td style=`"color:$statusColor;font-weight:bold;`">$healthText</td>`n"
$dashboardRows += "</tr>`n"
$totalIssues += $openIssues + $closedIssues
$totalActivity += $openIssues + $closedIssues + $openPRs + $mergedPRs
$totalOpenIssues += $openIssues
$totalPRs += $openPRs + $mergedPRs
$totalOpenPRs += $openPRs
$totalMergedPRs += $mergedPRs
$healthSum += $health
$relDisplay = if ($latestRelease -eq "none") { "" } else { Html-Escape $latestRelease }
$dashboardRows += @"
<tr>
<td><a href="https://gitlink.org.cn/$Org/$repo" target="_blank">$(Html-Escape $repo)</a></td>
<td>$openIssues</td><td>$closedIssues</td>
<td>$openPRs</td><td>$mergedPRs</td><td>$relDisplay</td>
<td><span class="badge $($grade.Class)">$health</span> <span class="grade $($grade.Class)">$($grade.Label)</span></td>
</tr>
"@
}
# -- Step 4: Generate HTML Dashboard --
Log-Title "Generating Dashboard"
$avgHealth = if ($repoList.Count -gt 0) { [int]($healthSum / $repoList.Count) } else { 0 }
# -- Step 5: Generate HTML Dashboard --
Log-Title "生成 HTML 看板"
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$repoCount = $repoList.Count
$html = '<!DOCTYPE html>
<html lang="en">
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Repo Collaboration Dashboard</title>
<title>多仓库协同看板 - $Org</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: bold; color: #333; }
.card.blue .value { color: #2196F3; }
.card.green .value { color: #4CAF50; }
.card.orange .value { color: #FF9800; }
.card.purple .value { color: #9C27B0; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
tr:hover { background: #f9f9f9; }
a { color: #2196F3; text-decoration: none; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
.container { max-width: 1280px; margin: 0 auto; }
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
.card .value { font-size: 30px; font-weight: 700; }
.card.blue .value { color: #2f80ed; }
.card.orange .value { color: #f2994a; }
.card.purple .value { color: #9b51e0; }
.card.green .value { color: #27ae60; }
.card.teal .value { color: #17a2b8; }
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f7f9fc; }
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
.grade { font-size: 13px; margin-left: 4px; }
.badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; }
.badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; }
.badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; }
.badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; }
.badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; }
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
summary:hover { background: #f7f9fc; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
.detail-table td { padding: 8px 12px; font-size: 13px; }
.detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<h1>Multi-Repo Collaboration Dashboard</h1>
<p class="timestamp">Generated: ' + $timestamp + ' | Organization: ' + $Org + '</p>
<h1>多仓库协同看板</h1>
<p class="timestamp">生成时间$timestamp 组织$Org</p>
<div class="summary">
<div class="card blue"><h3>Total Repos</h3><div class="value">' + $repoCount + '</div></div>
<div class="card orange"><h3>Open Issues</h3><div class="value">' + $totalOpenIssues + '</div></div>
<div class="card purple"><h3>Open PRs</h3><div class="value">' + $totalOpenPRs + '</div></div>
<div class="card green"><h3>Total Activity</h3><div class="value">' + $totalIssues + '</div></div>
<div class="card blue"><h3>仓库总数</h3><div class="value">$repoCount</div></div>
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$totalOpenIssues</div></div>
<div class="card purple"><h3>未合并 PR</h3><div class="value">$totalOpenPRs</div></div>
<div class="card green"><h3>已合并 PR</h3><div class="value">$totalMergedPRs</div></div>
<div class="card teal"><h3>平均健康度</h3><div class="value">$avgHealth</div></div>
</div>
<h2>仓库概览</h2>
<table>
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
<tbody>
' + $dashboardRows + '
$dashboardRows
</tbody>
</table>
<h2>仓库详情</h2>
$repoDetails
</div>
</body>
</html>'
</html>
"@
$html | Out-File -FilePath $Output -Encoding UTF8
Log-Ok "Dashboard saved to: $Output"
$utf8 = [System.Text.UTF8Encoding]::new($false)
[System.IO.File]::WriteAllText((Join-Path (Get-Location) $Output), $html, $utf8)
Log-Ok "看板已保存: $Output"
# -- Step 5: Coordinated Release --
# -- Step 6: Coordinated Release --
if ($Release) {
Log-Title "Coordinated Release: $Release"
Log-Title "协调发版: $Release"
foreach ($repo in $repoList) {
Log-Step "Creating release for $Org/$repo..."
$relBody = "Coordinated release $Release for $Org/$repo"
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
if ($relResult -and (Get-JsonOk $relResult)) {
Log-Ok "Release $Release created for $repo"
} else {
Log-Warn "Release creation failed for $repo (tag may already exist)"
if ($DryRun) {
Log-Warn "[DRY RUN] 将为以下 $($repoList.Count) 个仓库创建 Release ${Release}:"
foreach ($repo in $repoList) { Log-Warn " [DRY RUN] $Org/$repo -> $Release" }
} else {
$relOk = 0; $relFail = 0
foreach ($repo in $repoList) {
Log-Step "$Org/$repo 创建 Release..."
$relBody = "Coordinated release $Release for $Org/$repo"
$relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody
$ok = $false
if ($relResult) { try { $ok = ($relResult | ConvertFrom-Json).ok } catch { $ok = $false } }
if ($ok) { Log-Ok "Release $Release 已在 $repo 创建"; $relOk++ }
else { Log-Warn "Release 创建失败: $repo (tag 可能已存在)"; $relFail++ }
}
Log-Info "协调发版结果: 成功 $relOk / 失败 $relFail"
}
}
# ----------------------------------------------------------------
Log-Title "Multi-Repo Dashboard Complete"
Log-Title "多仓库看板完成"
# ----------------------------------------------------------------
Write-Host " Repos processed: $repoCount" -ForegroundColor Green
Write-Host " Total issues: $totalIssues (open: $totalOpenIssues)" -ForegroundColor Green
Write-Host " Total PRs: $totalPRs (open: $totalOpenPRs)" -ForegroundColor Green
Write-Host " Dashboard: $Output" -ForegroundColor Green
if ($Release) { Write-Host " Coordinated release: $Release" -ForegroundColor Green }
Write-Host " 处理仓库: $repoCount" -ForegroundColor Green
Write-Host " 未关闭 Issue: $totalOpenIssues" -ForegroundColor Green
Write-Host " 未合并 PR: $totalOpenPRs" -ForegroundColor Green
Write-Host " 已合并 PR: $totalMergedPRs" -ForegroundColor Green
Write-Host " 平均健康度: $avgHealth / 100" -ForegroundColor Green
Write-Host " 看板文件: $Output" -ForegroundColor Green
if ($Release) {
$suffix = if ($DryRun) { " (dry-run)" } else { "" }
Write-Host " 协调发版: $Release$suffix" -ForegroundColor Green
}
Write-Host ""
Write-Host "Open dashboard:" -ForegroundColor Cyan
Write-Host "打开看板:" -ForegroundColor Cyan
Write-Host " Start-Process $Output"

View File

@ -3,27 +3,27 @@
# Scenario 4: Multi-Repo Collaboration
# Flow: Cross-repo issue tracking → PR status dashboard → Coordinated release
#
# Commands/Skills chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch issues from each repo
# 3. pr +list -- fetch PRs from each repo
# 4. pr +view -- get PR details for dashboard
# 5. release +list -- check release status across repos
# 6. release +create -- coordinated release
# 7. Generate HTML dashboard
# Commands chained:
# 1. repo +list -- list all repos in org
# 2. issue +list -- fetch open/closed issues from each repo
# 3. pr +list -- fetch open/merged PRs from each repo
# 4. release +list -- check latest release across repos
# 5. Generate HTML dashboard (summary cards + table + per-repo details)
# 6. release +create -- (optional) coordinated release across all repos
# ─────────────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
usage() {
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--dry-run]"
echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--output FILE] [--dry-run]"
echo ""
echo " --org ORG Organization name"
echo " --org ORG Organization / user name (required)"
echo " --repos REPO1,REPO2 Comma-separated repo list (default: all repos in org)"
echo " --release TAG Coordinated release tag to create"
echo " --release TAG Coordinated release tag to create across all repos"
echo " --output FILE Output HTML dashboard file (default: dashboard.html)"
echo " --dry-run Preview actions without executing"
echo " --detail-limit N Max open issues/PRs listed per repo in details (default: 10)"
echo " --dry-run Preview actions without creating releases"
exit 1
}
@ -32,16 +32,18 @@ ORG=""
REPOS=""
RELEASE_TAG=""
OUTPUT_FILE="dashboard.html"
DETAIL_LIMIT=10
while [[ $# -gt 0 ]]; do
case "$1" in
--org) ORG="$2"; shift 2 ;;
--repos) REPOS="$2"; shift 2 ;;
--release) RELEASE_TAG="$2"; shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
--org) ORG="$2"; shift 2 ;;
--repos) REPOS="$2"; shift 2 ;;
--release) RELEASE_TAG="$2"; shift 2 ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
--detail-limit) DETAIL_LIMIT="$2"; shift 2 ;;
--dry-run) DRY_RUN="true"; shift ;;
--help|-h) usage ;;
*) log_err "Unknown arg: $1"; usage ;;
esac
done
@ -52,214 +54,330 @@ fi
check_auth
# ── Step 1: List Repositories ────────────────────────────────────────
log_title "Multi-Repo Collaboration Dashboard"
# ── HTML escape helper ───────────────────────────────────────────────
html_escape() {
local s="$1"
s="${s//&/&amp;}"
s="${s//</&lt;}"
s="${s//>/&gt;}"
s="${s//\"/&quot;}"
echo "$s"
}
log_step "Fetching repositories for org: $ORG..."
# ── Health score (0-100) — aligned with gitlink-health skill ─────────
# Model: start 100, deduct. Core 75 (issue efficiency / PR efficiency /
# activity, -25 each), auxiliary 25 (issue backlog -10, PR backlog -10,
# no recent release -5). Time-based core metrics are approximated by
# list-level ratios for a fast multi-repo scan.
# release_age: days since latest release, -1 = no release.
compute_health() {
local open_issues="$1" closed_issues="$2" open_prs="$3" merged_prs="$4" release_age="$5"
awk -v oi="$open_issues" -v ci="$closed_issues" -v op="$open_prs" \
-v mp="$merged_prs" -v age="$release_age" 'BEGIN {
health = 100
# ── 核心指标 (75) ──
# Issue 处理效率 (proxy for 响应慢): 解决率 < 50% 扣 25
ti = oi + ci
res = (ti > 0) ? ci / ti : 1
if (res < 0.5) health -= 25
# PR 合并效率 (proxy for 合并慢): 合并率 < 50% 扣 25
tp = op + mp
mr = (tp > 0) ? mp / tp : 1
if (mr < 0.5) health -= 25
# 活跃度低: 无任何已完成工作 (合并 PR + 关闭 Issue == 0) 扣 25
if ((mp + ci) == 0) health -= 25
# ── 辅助指标 (25) ──
if (oi > 20) health -= 10 # Issue 积压
if (op > 10) health -= 10 # PR 积压
if (age < 0 || age > 30) health -= 5 # 无近期发布 (>30天或无)
if (health < 0) health = 0
if (health > 100) health = 100
printf "%d", health
}'
}
# Map score → (grade, css class) — 5 bands per gitlink-health skill
health_grade() {
local score="$1"
if [[ "$score" -ge 90 ]]; then echo "优秀|excellent"
elif [[ "$score" -ge 70 ]]; then echo "良好|good"
elif [[ "$score" -ge 50 ]]; then echo "一般|moderate"
elif [[ "$score" -ge 30 ]]; then echo "需关注|attention"
else echo "严重|critical"
fi
}
# ── Step 1: List Repositories ────────────────────────────────────────
log_title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)"
log_step "获取组织 $ORG 下的仓库列表..."
REPOS_JSON=$(gl_check repo +list --user "$ORG" --limit 100)
# Response may have .data.projects[] or .data[]
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq '(.data.projects // .data | if type == "array" then . else [] end) | length')
log_ok "Found $ALL_REPO_COUNT repositories"
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq "$REPOS_DATA_PATH | length")
log_ok "组织下共有 $ALL_REPO_COUNT 个仓库"
# Filter repos if --repos specified
REPO_LIST=()
if [[ -n "$REPOS" ]]; then
IFS=',' read -ra REPO_LIST <<< "$REPOS"
log_info "Filtering to specified repos: ${REPO_LIST[*]}"
log_info "仅处理指定仓库: ${REPO_LIST[*]}"
else
REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)'
for i in $(seq 0 $((ALL_REPO_COUNT - 1))); do
RNAME=$(echo "$REPOS_JSON" | jq -r "$REPOS_DATA_PATH[$i].name // $REPOS_DATA_PATH[$i].identifier // empty")
[[ -n "$RNAME" ]] && REPO_LIST+=("$RNAME")
done
fi
log_ok "Will process ${#REPO_LIST[@]} repositories"
if [[ "${#REPO_LIST[@]}" -eq 0 ]]; then
log_err "没有可处理的仓库。请检查 --org 是否正确,或用 --repos 指定。"
exit 1
fi
log_ok "将处理 ${#REPO_LIST[@]} 个仓库"
# ── Step 2-3: Collect Issues and PRs from each repo ──────────────────
log_title "Collecting Data Across Repos"
# ── Step 2-4: Collect data across repos ──────────────────────────────
log_title "跨仓库数据采集"
# Data arrays for dashboard
DASHBOARD_ROWS=""
TOTAL_ISSUES=0
TOTAL_PRS=0
REPO_DETAILS=""
TOTAL_ACTIVITY=0
TOTAL_OPEN_ISSUES=0
TOTAL_OPEN_PRS=0
TOTAL_MERGED_PRS=0
HEALTH_SUM=0
ISSUE_DATA_PATH='(.data.issues // .data | if type == "array" then . else [] end)'
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
for repo in "${REPO_LIST[@]}"; do
divider
log_step "Processing $ORG/$repo..."
log_step "处理 $ORG/$repo..."
# Fetch open issues
ISSUES_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state open --limit 50)
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$OPEN_ISSUES" =~ ^[0-9]+$ ]] || OPEN_ISSUES=0
# Fetch closed issues (recent)
CLOSED_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state closed --limit 50)
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq '.data.issues | length' 2>/dev/null || echo "0")
CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$CLOSED_ISSUES" =~ ^[0-9]+$ ]] || CLOSED_ISSUES=0
# Fetch open PRs
PRS_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state open --limit 50)
OPEN_PRS=$(echo "$PRS_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
OPEN_PRS=$(echo "$PRS_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$OPEN_PRS" =~ ^[0-9]+$ ]] || OPEN_PRS=0
# Fetch merged PRs (recent)
MERGED_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state merged --limit 50)
MERGED_PRS=$(echo "$MERGED_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0")
MERGED_PRS=$(echo "$MERGED_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0")
[[ "$MERGED_PRS" =~ ^[0-9]+$ ]] || MERGED_PRS=0
# Fetch latest release
RELEASES_JSON=$(gl_run release +list --owner "$ORG" --repo "$repo" --limit 1)
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r '.data.releases[0].tag_name // .data.releases[0].name // "none"' 2>/dev/null)
REL_ARR='(.data.releases // .data | if type == "array" then . else [] end)'
LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].tag_name // $REL_ARR[0].name // \"none\"" 2>/dev/null)
[[ -z "$LATEST_RELEASE" || "$LATEST_RELEASE" == "null" ]] && LATEST_RELEASE="none"
log_ok "$repo: Issues(open:$OPEN_ISSUES closed:$CLOSED_ISSUES) PRs(open:$OPEN_PRS merged:$MERGED_PRS) Release:$LATEST_RELEASE"
# Release age in days (-1 if none / unparseable)
RELEASE_AGE=-1
if [[ "$LATEST_RELEASE" != "none" ]]; then
REL_DATE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].created_at // $REL_ARR[0].created_on // $REL_ARR[0].published_at // empty" 2>/dev/null)
REL_DATE="${REL_DATE%% *}"; REL_DATE="${REL_DATE%%T*}"
if [[ "$REL_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
RELEASE_AGE=$(days_between "$REL_DATE" "$(date_today)")
else
RELEASE_AGE=0 # has release but no parseable date → treat as recent
fi
fi
# Get PR details for open PRs
PR_DETAILS=""
PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)'
if [[ "$OPEN_PRS" -gt 0 ]] && [[ "$OPEN_PRS" != "null" ]]; then
for pi in $(seq 0 $((OPEN_PRS > 5 ? 4 : OPEN_PRS - 1))); do
PR_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
PR_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // empty")
PR_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
PR_DETAILS+="<tr><td>#$PR_ID</td><td>$PR_TITLE</td><td>@$PR_AUTHOR</td><td>open</td></tr>"
# Health score (aligned with gitlink-health skill)
HEALTH=$(compute_health "$OPEN_ISSUES" "$CLOSED_ISSUES" "$OPEN_PRS" "$MERGED_PRS" "$RELEASE_AGE")
IFS='|' read -r GRADE_LABEL GRADE_CLASS <<< "$(health_grade "$HEALTH")"
log_ok "$repo: Issue(开:$OPEN_ISSUES 闭:$CLOSED_ISSUES) PR(开:$OPEN_PRS 合:$MERGED_PRS) Release:$LATEST_RELEASE 健康度:$HEALTH($GRADE_LABEL)"
# ── Collect open issue details ──
ISSUE_ROWS=""
if [[ "$OPEN_ISSUES" -gt 0 ]]; then
limit=$(( OPEN_ISSUES < DETAIL_LIMIT ? OPEN_ISSUES : DETAIL_LIMIT ))
for ii in $(seq 0 $((limit - 1))); do
I_ID=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].id // $ISSUE_DATA_PATH[$ii].number // empty")
I_TITLE=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].name // $ISSUE_DATA_PATH[$ii].subject // $ISSUE_DATA_PATH[$ii].title // empty")
I_AUTHOR=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].author_name // $ISSUE_DATA_PATH[$ii].author_login // $ISSUE_DATA_PATH[$ii].author.login // \"unknown\"")
ISSUE_ROWS+="<tr><td>#$(html_escape "$I_ID")</td><td>$(html_escape "$I_TITLE")</td><td>@$(html_escape "$I_AUTHOR")</td></tr>"
done
fi
# Accumulate totals
TOTAL_ISSUES=$((TOTAL_ISSUES + OPEN_ISSUES + CLOSED_ISSUES))
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
TOTAL_PRS=$((TOTAL_PRS + OPEN_PRS + MERGED_PRS))
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
# ── Collect open PR details ──
PR_ROWS=""
if [[ "$OPEN_PRS" -gt 0 ]]; then
limit=$(( OPEN_PRS < DETAIL_LIMIT ? OPEN_PRS : DETAIL_LIMIT ))
for pi in $(seq 0 $((limit - 1))); do
P_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty")
P_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // $PR_DATA_PATH[$pi].name // empty")
P_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_name // $PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"")
PR_ROWS+="<tr><td>#$(html_escape "$P_ID")</td><td>$(html_escape "$P_TITLE")</td><td>@$(html_escape "$P_AUTHOR")</td></tr>"
done
fi
# Add to dashboard rows
STATUS_COLOR="green"
[[ "$OPEN_ISSUES" -gt 10 ]] && STATUS_COLOR="orange"
[[ "$OPEN_ISSUES" -gt 20 ]] && STATUS_COLOR="red"
# ── Per-repo collapsible detail section ──
[[ -z "$ISSUE_ROWS" ]] && ISSUE_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未关闭 Issue</td></tr>"
[[ -z "$PR_ROWS" ]] && PR_ROWS="<tr><td colspan=\"3\" class=\"empty\">无未合并 PR</td></tr>"
REPO_DETAILS+="<details><summary><span class=\"badge $GRADE_CLASS\">$HEALTH</span> $(html_escape "$repo") — Issue 开 $OPEN_ISSUES / PR 开 $OPEN_PRS</summary>
<div class=\"detail-grid\">
<div><h4>未关闭 Issue最多 $DETAIL_LIMIT 条)</h4>
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$ISSUE_ROWS</tbody></table></div>
<div><h4>未合并 PR最多 $DETAIL_LIMIT 条)</h4>
<table class=\"detail-table\"><thead><tr><th>编号</th><th>标题</th><th>作者</th></tr></thead><tbody>$PR_ROWS</tbody></table></div>
</div>
</details>"
# Accumulate totals
TOTAL_ACTIVITY=$((TOTAL_ACTIVITY + OPEN_ISSUES + CLOSED_ISSUES + OPEN_PRS + MERGED_PRS))
TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES))
TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS))
TOTAL_MERGED_PRS=$((TOTAL_MERGED_PRS + MERGED_PRS))
HEALTH_SUM=$((HEALTH_SUM + HEALTH))
REL_DISPLAY="$LATEST_RELEASE"
[[ "$REL_DISPLAY" == "none" ]] && REL_DISPLAY="—"
DASHBOARD_ROWS+="<tr>
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\">$repo</a></td>
<td><a href=\"https://gitlink.org.cn/$ORG/$repo\" target=\"_blank\">$(html_escape "$repo")</a></td>
<td>$OPEN_ISSUES</td>
<td>$CLOSED_ISSUES</td>
<td>$OPEN_PRS</td>
<td>$MERGED_PRS</td>
<td>$LATEST_RELEASE</td>
<td style=\"color:$STATUS_COLOR;font-weight:bold;\">$(
[[ "$OPEN_ISSUES" -le 5 ]] && echo "Healthy" || \
[[ "$OPEN_ISSUES" -le 15 ]] && echo "Moderate" || echo "Needs Attention"
)</td>
<td>$(html_escape "$REL_DISPLAY")</td>
<td><span class=\"badge $GRADE_CLASS\">$HEALTH</span> <span class=\"grade $GRADE_CLASS\">$GRADE_LABEL</span></td>
</tr>"
done
# ── Step 4: Generate HTML Dashboard ──────────────────────────────────
log_title "Generating Dashboard"
# Average health
AVG_HEALTH=0
[[ "${#REPO_LIST[@]}" -gt 0 ]] && AVG_HEALTH=$((HEALTH_SUM / ${#REPO_LIST[@]}))
log_step "Creating HTML dashboard..."
# ── Step 5: Generate HTML Dashboard ──────────────────────────────────
log_title "生成 HTML 看板"
log_step "写入 $OUTPUT_FILE..."
cat > "$OUTPUT_FILE" << 'HTMLEOF'
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
cat > "$OUTPUT_FILE" << HTMLEOF
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Repo Collaboration Dashboard</title>
<title>多仓库协同看板 - $ORG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #333; margin-bottom: 20px; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 30px; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h3 { color: #666; font-size: 14px; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: bold; color: #333; }
.card.blue .value { color: #2196F3; }
.card.green .value { color: #4CAF50; }
.card.orange .value { color: #FF9800; }
.card.purple .value { color: #9C27B0; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
th { background: #2196F3; color: white; padding: 12px 16px; text-align: left; }
td { padding: 12px 16px; border-bottom: 1px solid #eee; }
tr:hover { background: #f9f9f9; }
a { color: #2196F3; text-decoration: none; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; background: #f0f2f5; padding: 24px; color: #1f2329; }
.container { max-width: 1280px; margin: 0 auto; }
h1 { color: #1f2329; margin-bottom: 6px; font-size: 26px; }
.timestamp { color: #8a919f; font-size: 13px; margin-bottom: 24px; }
.summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 28px; }
.card { background: #fff; border-radius: 10px; padding: 18px 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.card h3 { color: #8a919f; font-size: 13px; margin-bottom: 8px; font-weight: 500; }
.card .value { font-size: 30px; font-weight: 700; }
.card.blue .value { color: #2f80ed; }
.card.orange .value { color: #f2994a; }
.card.purple .value { color: #9b51e0; }
.card.green .value { color: #27ae60; }
.card.teal .value { color: #17a2b8; }
h2 { font-size: 18px; margin: 24px 0 14px; color: #1f2329; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
th { background: #2f80ed; color: #fff; padding: 12px 16px; text-align: left; font-weight: 500; font-size: 14px; }
td { padding: 11px 16px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f7f9fc; }
a { color: #2f80ed; text-decoration: none; font-weight: 500; }
a:hover { text-decoration: underline; }
.timestamp { color: #999; font-size: 14px; margin-bottom: 20px; }
.badge { display: inline-block; min-width: 34px; text-align: center; padding: 2px 8px; border-radius: 12px; color: #fff; font-weight: 700; font-size: 13px; }
.grade { font-size: 13px; margin-left: 4px; }
.excellent, .badge.excellent { background: #27ae60; } .grade.excellent { color: #27ae60; background: none; }
.good, .badge.good { background: #2f80ed; } .grade.good { color: #2f80ed; background: none; }
.moderate, .badge.moderate { background: #f2c94c; } .grade.moderate { color: #d9a400; background: none; }
.attention, .badge.attention { background: #f2994a; } .grade.attention { color: #f2994a; background: none; }
.critical, .badge.critical { background: #eb5757; } .grade.critical { color: #eb5757; background: none; }
details { background: #fff; border-radius: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); overflow: hidden; }
summary { padding: 14px 18px; cursor: pointer; font-weight: 600; font-size: 15px; user-select: none; }
summary:hover { background: #f7f9fc; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; padding: 0 18px 18px; }
.detail-table th { background: #eef2f8; color: #4a5568; padding: 8px 12px; font-size: 13px; }
.detail-table td { padding: 8px 12px; font-size: 13px; }
.detail-table h4, .detail-grid h4 { font-size: 14px; margin-bottom: 8px; color: #4a5568; }
.empty { color: #b0b7c3; text-align: center; font-style: italic; }
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } .detail-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<h1>Multi-Repo Collaboration Dashboard</h1>
<p class="timestamp">Generated: TIMESTAMP_PLACEHOLDER | Organization: ORG_PLACEHOLDER</p>
<h1>多仓库协同看板</h1>
<p class="timestamp">生成时间:$TIMESTAMP 组织:$ORG</p>
<div class="summary">
<div class="card blue"><h3>Total Repos</h3><div class="value">REPOS_COUNT</div></div>
<div class="card orange"><h3>Open Issues</h3><div class="value">OPEN_ISSUES_COUNT</div></div>
<div class="card purple"><h3>Open PRs</h3><div class="value">OPEN_PRS_COUNT</div></div>
<div class="card green"><h3>Total Activity</h3><div class="value">TOTAL_ACTIVITY</div></div>
<div class="card blue"><h3>仓库总数</h3><div class="value">${#REPO_LIST[@]}</div></div>
<div class="card orange"><h3>未关闭 Issue</h3><div class="value">$TOTAL_OPEN_ISSUES</div></div>
<div class="card purple"><h3>未合并 PR</h3><div class="value">$TOTAL_OPEN_PRS</div></div>
<div class="card green"><h3>已合并 PR</h3><div class="value">$TOTAL_MERGED_PRS</div></div>
<div class="card teal"><h3>平均健康度</h3><div class="value">$AVG_HEALTH</div></div>
</div>
<h2>仓库概览</h2>
<table>
<thead><tr><th>Repository</th><th>Open Issues</th><th>Closed Issues</th><th>Open PRs</th><th>Merged PRs</th><th>Latest Release</th><th>Health</th></tr></thead>
<tbody>DASHBOARD_ROWS_PLACEHOLDER</tbody>
<thead><tr><th>仓库</th><th>未关闭 Issue</th><th>已关闭 Issue</th><th>未合并 PR</th><th>已合并 PR</th><th>最新 Release</th><th>健康度</th></tr></thead>
<tbody>$DASHBOARD_ROWS</tbody>
</table>
<h2>仓库详情</h2>
$REPO_DETAILS
</div>
</body>
</html>
HTMLEOF
# Replace placeholders using temp file approach for complex content
TEMP_HTML=$(mktemp)
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log_ok "看板已保存: $OUTPUT_FILE"
while IFS= read -r line; do
line="${line//TIMESTAMP_PLACEHOLDER/$TIMESTAMP}"
line="${line//ORG_PLACEHOLDER/$ORG}"
line="${line//REPOS_COUNT/${#REPO_LIST[@]}}"
line="${line//OPEN_ISSUES_COUNT/$TOTAL_OPEN_ISSUES}"
line="${line//OPEN_PRS_COUNT/$TOTAL_OPEN_PRS}"
line="${line//TOTAL_ACTIVITY/$TOTAL_ISSUES}"
line="${line//DASHBOARD_ROWS_PLACEHOLDER/$DASHBOARD_ROWS}"
echo "$line"
done < "$OUTPUT_FILE" > "$TEMP_HTML"
mv "$TEMP_HTML" "$OUTPUT_FILE"
log_ok "Dashboard saved to: $OUTPUT_FILE"
# ── Step 5: Coordinated Release ──────────────────────────────────────
# ── Step 6: Coordinated Release ──────────────────────────────────────
if [[ -n "$RELEASE_TAG" ]]; then
log_title "Coordinated Release: $RELEASE_TAG"
log_title "协调发版: $RELEASE_TAG"
RELEASE_BODY="# Coordinated Release: $RELEASE_TAG
if [[ "$DRY_RUN" == "true" ]]; then
log_warn "[DRY RUN] 将为以下 ${#REPO_LIST[@]} 个仓库创建 Release $RELEASE_TAG:"
for repo in "${REPO_LIST[@]}"; do
log_warn " [DRY RUN] $ORG/$repo$RELEASE_TAG"
done
else
RELEASE_OK=0
RELEASE_FAIL=0
for repo in "${REPO_LIST[@]}"; do
log_step "$ORG/$repo 创建 Release..."
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
## Repos Included
"
for repo in "${REPO_LIST[@]}"; do
log_step "Creating release for $ORG/$repo..."
RELEASE_BODY+="- $ORG/$repo"$'\n'
RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \
--tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \
--body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release $RELEASE_TAG created for $repo"
else
log_warn "Release creation failed for $repo (tag may already exist)"
fi
done
RELEASE_BODY+=$'\n'"---"$'\n'"*Coordinated release by gitlink-cli multi-repo-collab workflow*"
if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then
log_ok "Release $RELEASE_TAG 已在 $repo 创建"
RELEASE_OK=$((RELEASE_OK + 1))
else
log_warn "Release 创建失败: $repo (tag 可能已存在)"
RELEASE_FAIL=$((RELEASE_FAIL + 1))
fi
done
log_info "协调发版结果: 成功 $RELEASE_OK / 失败 $RELEASE_FAIL"
fi
fi
# ─────────────────────────────────────────────────────────────────────
log_title "Multi-Repo Dashboard Complete"
log_title "多仓库看板完成"
# ─────────────────────────────────────────────────────────────────────
echo -e "${GREEN}Summary:${NC}"
echo " Repos processed: ${#REPO_LIST[@]}"
echo " Total issues: $TOTAL_ISSUES (open: $TOTAL_OPEN_ISSUES)"
echo " Total PRs: $TOTAL_PRS (open: $TOTAL_OPEN_PRS)"
echo " Dashboard: $OUTPUT_FILE"
[[ -n "$RELEASE_TAG" ]] && echo " Coordinated release: $RELEASE_TAG"
echo -e "${GREEN}汇总:${NC}"
echo " 处理仓库: ${#REPO_LIST[@]}"
echo " 未关闭 Issue: $TOTAL_OPEN_ISSUES"
echo " 未合并 PR: $TOTAL_OPEN_PRS"
echo " 已合并 PR: $TOTAL_MERGED_PRS"
echo " 平均健康度: $AVG_HEALTH / 100"
echo " 看板文件: $OUTPUT_FILE"
[[ -n "$RELEASE_TAG" ]] && echo " 协调发版: $RELEASE_TAG$([[ "$DRY_RUN" == "true" ]] && echo " (dry-run)")"
echo ""
echo -e "${CYAN}Open dashboard:${NC}"
echo -e "${CYAN}打开看板:${NC}"
echo " start $OUTPUT_FILE # Windows"
echo " xdg-open $OUTPUT_FILE # Linux"
echo " open $OUTPUT_FILE # macOS"
echo ""

View File

@ -28,7 +28,7 @@ param(
)
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot/lib/common.psm1" -Force
Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue
if ($Help) {
Write-Host "Usage: powershell 05-contributor-growth.ps1 -Owner OWNER -Repo REPO [-Sample N] [-Award] [-DryRun]"
@ -55,11 +55,11 @@ Log-Title "Contributor Growth System: $Owner/$Repo"
Log-Step "Collecting data..."
$issuesOpen = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100"
$issuesClosed = Invoke-GL "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
$issuesClosed = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100"
$openCount = if ($issuesOpen) { @($issuesOpen.data.issues).Count } else { 0 }
$closedCount = if ($issuesClosed) { @($issuesClosed.data.issues).Count } else { 0 }
$prsMerged = Invoke-GL "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
$prsMerged = Invoke-GLCheck "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100"
$prMergedData = @()
if ($prsMerged) {
$pd = $prsMerged.data
@ -69,7 +69,7 @@ if ($prsMerged) {
}
$prMergedCount = $prMergedData.Count
$membersJson = Invoke-GL "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100"
$membersJson = Invoke-GLCheck "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100"
$memberData = @()
if ($membersJson) {
$md = $membersJson.data
@ -119,7 +119,7 @@ for ($i = 0; $i -lt $prMergedCount; $i++) {
$contribData[$author].Merged++
}
if ($i -lt $prSample -and $prId -and $author) {
$filesJson = Invoke-GL "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId
$filesJson = Invoke-GLCheck "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId
if ($filesJson -and $filesJson.data.files) {
foreach ($f in $filesJson.data.files) {
$add = if ($f.additions) { $f.additions } elseif ($f.addition) { $f.addition } else { 0 }
@ -148,9 +148,9 @@ if ($issuesOpen) {
for ($i = 0; $i -lt $commentSample; $i++) {
$id = $openIssuesArr[$i].id
if (-not $id) { continue }
$detail = Invoke-GL "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id
if ($detail) {
$commentCount = if ($detail.data.comment_journals_count) { $detail.data.comment_journals_count } else { 0 }
$detailJson = Invoke-GLCheck "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id
if ($detailJson) {
$commentCount = if ($detailJson.data.comment_journals_count) { $detailJson.data.comment_journals_count } else { 0 }
if ($commentCount -gt 0) {
$author = $openIssuesArr[$i].author.login
if ($author) {
@ -371,11 +371,27 @@ $wikiContent += $wikiRankRows + "`n"
$wikiContent += "---" + "`n"
$wikiContent += "*Auto-generated by gitlink-cli*"
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent
if ($wikiResult -and (Get-JsonOk $wikiResult)) {
Log-Ok "Published to Wiki: $wikiTitle"
$wikiResult = Invoke-GL "wiki", "+update", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--cover", $wikiContent
if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) {
Log-Ok "Published to Wiki (updated): $wikiTitle"
} else {
Log-Warn "Wiki publish failed"
Log-Info "Update failed (page may not exist), trying create..."
$wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent
if ($wikiResult) {
try {
$wikiObj = $wikiResult | ConvertFrom-Json
if ($wikiObj.ok) {
Log-Ok "Published to Wiki (created): $wikiTitle"
} else {
$wikiErr = if ($wikiObj.error.message) { $wikiObj.error.message } elseif ($wikiObj.message) { $wikiObj.message } else { "unknown error" }
Log-Warn "Wiki publish failed: $wikiErr"
}
} catch {
Log-Warn "Wiki publish failed: non-JSON response from API"
}
} else {
Log-Warn "Wiki publish failed: no response from API"
}
}
# -- Step 7: Award Badges (optional) --
@ -409,8 +425,8 @@ if ($Award) {
$issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $issueTitle, "--body", $issueBody
if ($issueResult) {
try {
$issueJson = $issueResult
$issueNum = if ($issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.data.number) { $issueJson.data.number } else { $null }
$issueJson = $issueResult | ConvertFrom-Json
$issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null }
if ($issueNum) {
Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $issueNum, "--labels", "badge" | Out-Null
Log-Ok "Badge issue created: #$issueNum - $issueTitle ($($users.Count) recipients)"

View File

@ -60,7 +60,7 @@ gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limi
|---|------|------|---------|-------------|
| 1 | 社区运营自动化 | `01-community-ops.sh` | 7 个 | Issue 积压无人处理、周报手写、Release Notes 手动整理 |
| 2 | 代码质量看门人 | `02-code-quality-gatekeeper.sh` | 7 个 | PR 审查效率低、质量标准不统一、AI 代码审查(基于 gitlink-code-review skill |
| 3 | 项目一键初始化 | `03-project-init.sh` | 6 个 | 新建项目重复劳动多、Issue/文档/分支保护手动配 |
| 3 | 项目一键初始化 | `03-project-init.sh` | 8 个 | 新建项目重复劳动多、README/许可证/CI/Issue/里程碑手动配 |
| 4 | 多仓库协同 | `04-multi-repo-collab.sh` | 7 个 | 跨仓库状态分散、缺乏统一视图 |
| 5 | 贡献者成长体系 | `05-contributor-growth.sh` | 6 个 | 贡献者活跃度难追踪、缺乏激励机制 |
@ -236,21 +236,29 @@ bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cl
## 场景三:项目一键初始化
**脚本**: `03-project-init.sh`
**脚本**: `03-project-init.sh` / `03-project-init.ps1`
### 解决什么问题
新建项目仓库后,还要手动创建 README、写 CONTRIBUTING 指南、创建初始 Issue、设置分支保护、打初始 Release。一条命令搞定全部。
新建项目仓库后,还要手动创建 README、写 LICENSE、配置 CI/CD、创建 Issue 和里程碑、设置分支保护、打初始 Release。一条命令搞定全部。
### 工作流程
```
repo +create → 创建仓库
wiki +create → 生成 README根据语言模板
wiki +create → 生成 CONTRIBUTING 贡献指南
git clone → 克隆空仓库到本地
issue +create × 5 → 创建初始待办 Issue:
生成文件 → README.md (语言模板) + LICENSE (MIT) + CONTRIBUTING.md + .gitlink-ci.yml
git add/commit/push → 将脚手架文件推送到仓库
milestone +create × 3 → 创建项目里程碑:
- v0.1.0 - MVP
- v0.2.0 - Feature Complete
- v1.0.0 - Production Ready
issue +create × 5 → 创建初始待办 Issue (打标签):
- 搭建 CI/CD 流水线
- 编写项目文档
- 建立代码审查流程
@ -267,15 +275,19 @@ release +create → 创建 v0.1.0 初始版本
| 步骤 | 命令 | 作用 |
|------|------|------|
| 1 | `repo +create` | 创建新仓库 |
| 2 | `wiki +create` | 生成 README支持 Go/Python/Node/Java |
| 3 | `wiki +create` | 生成 CONTRIBUTING 贡献指南 |
| 4 | `issue +create` | 创建 5 个初始 Issue 并打标签 |
| 5 | `branch +protect` | 设置 master 分支保护规则 |
| 6 | `release +create` | 创建 v0.1.0 初始版本 |
| 2 | `git clone` | 克隆空仓库到本地临时目录 |
| 3 | 文件生成 | 生成 README.md / LICENSE / CONTRIBUTING.md / .gitlink-ci.yml |
| 4 | `git add/commit/push` | 推送脚手架文件到 master 分支 |
| 5 | `milestone +create` | 创建 3 个项目里程碑 (MVP → Production) |
| 6 | `issue +create` | 创建 5 个初始 Issue 并打标签 |
| 7 | `branch +protect` | 设置 master 分支保护规则 |
| 8 | `release +create` | 创建 v0.1.0 初始版本 |
### 输出有什么用
- **开箱即用**: 新成员克隆后就知道怎么构建、测试、贡献
- **开箱即用**: 克隆后仓库已有 README、LICENSE、CI 配置,可直接开始开发
- **仓库内文件**: README/LICENSE/CONTRIBUTING/CI 是仓库里的真实文件,不是 Wiki 页面
- **里程碑规划**: 从 MVP 到正式版的路线图已建立
- **标准化 Issue**: 关键待办已创建好,团队可直接认领
- **分支保护**: 防止直接 push 到 master强制走 PR 流程
- **首个 Release**: 项目从创建之初就有版本管理

View File

@ -51,7 +51,7 @@ metadata:
→ PR Review、AI 四维度评分、自动合并
3. 项目一键初始化
→ 创建仓库、README、CI 配置、初始 Issues、分支保护
→ 创建仓库、README/LICENSE/CI 文件、里程碑、初始 Issues、分支保护
4. 多仓库协同
→ 跨仓库 Issue/PR 追踪、状态 Dashboard、协同发版

View File

@ -1,11 +1,11 @@
# GitLink 科研辅助 — 场景 1仓库级科研项目洞察 (PowerShell)
# GitLink Research - Scenario 1: Repository Project Insight (PowerShell)
param(
[string]$Owner, [string]$Repo, [string]$Output = "",
[switch]$NoWiki, [switch]$DryRun
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module "$ScriptDir\lib\common.psm1" -Force
Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue
$ErrorActionPreference = "Continue"
Check-Auth
@ -16,48 +16,50 @@ $OutputDir = Join-Path $ScriptDir "..\output"
if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null }
$OutputFile = if ($Output) { $Output } else { Join-Path $OutputDir "research-insights-${Repo}-${Today}.html" }
Log-Title "GitLink 科研辅助 — 仓库级项目洞察"
Log-Title "GitLink Research - Repository Project Insight"
# 1. Repo metadata
Log-Step "1/6 获取仓库元数据..."
Log-Step "1/6 Fetching repo metadata..."
$repoJson = Invoke-GLCheck @("repo", "+info", "--owner", $Owner, "--repo", $Repo)
$repoName = $repoJson.data.name ?? $repoJson.data.full_name ?? $Repo
$repoDesc = $repoJson.data.description ?? "No description"
$repoLang = $repoJson.data.language ?? "Unknown"
$stars = [int]($repoJson.data.stars_count ?? $repoJson.data.stars ?? 0)
$forks = [int]($repoJson.data.forks_count ?? $repoJson.data.forks ?? 0)
$openIssues = [int]($repoJson.data.open_issues_count ?? 0)
$updatedAt = $repoJson.data.updated_at ?? ""
$repoName = if ($repoJson.data.name) { $repoJson.data.name } elseif ($repoJson.data.full_name) { $repoJson.data.full_name } else { $Repo }
$repoDesc = if ($repoJson.data.description) { $repoJson.data.description } else { "No description" }
$repoLang = if ($repoJson.data.language) { $repoJson.data.language } else { "Unknown" }
$stars = if ($repoJson.data.stars_count) { [int]$repoJson.data.stars_count } elseif ($repoJson.data.stars) { [int]$repoJson.data.stars } else { 0 }
$forks = if ($repoJson.data.forks_count) { [int]$repoJson.data.forks_count } elseif ($repoJson.data.forks) { [int]$repoJson.data.forks } else { 0 }
$openIssues = if ($repoJson.data.open_issues_count) { [int]$repoJson.data.open_issues_count } else { 0 }
$updatedAt = if ($repoJson.data.updated_at) { $repoJson.data.updated_at } else { "" }
Log-Info " 名称: $repoName | 语言: $repoLang | Stars: $stars"
Log-Info " Name: $repoName | Lang: $repoLang | Stars: $stars"
# 2. Issues
Log-Step "2/6 收集 Issue 数据..."
$openIssuesJson = Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100")
$closedIssuesJson = Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100")
$totalOpen = if ($openIssuesJson.ok) { @($openIssuesJson.data.issues ?? $openIssuesJson.data).Count } else { 0 }
$totalClosed = if ($closedIssuesJson.ok) { @($closedIssuesJson.data.issues ?? $closedIssuesJson.data).Count } else { 0 }
Log-Step "2/6 Collecting Issue data..."
$openIssuesJson = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100")
$closedIssuesJson = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100")
$totalOpen = if ($openIssuesJson.ok) { $d = if ($openIssuesJson.data.issues) { $openIssuesJson.data.issues } else { $openIssuesJson.data }; @($d).Count } else { 0 }
$totalClosed = if ($closedIssuesJson.ok) { $d = if ($closedIssuesJson.data.issues) { $closedIssuesJson.data.issues } else { $closedIssuesJson.data }; @($d).Count } else { 0 }
# 3. PRs
Log-Step "3/6 收集 PR 数据..."
$mergedPrsJson = Invoke-GL @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100")
$openPrsJson = Invoke-GL @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50")
$totalMerged = if ($mergedPrsJson.ok) { @($mergedPrsJson.data.issues ?? $mergedPrsJson.data.pulls ?? $mergedPrsJson.data).Count } else { 0 }
$totalOpenPrs = if ($openPrsJson.ok) { @($openPrsJson.data.issues ?? $openPrsJson.data.pulls ?? $openPrsJson.data).Count } else { 0 }
Log-Step "3/6 Collecting PR data..."
$mergedPrsJson = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100")
$openPrsJson = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50")
$md = if ($mergedPrsJson.data.issues) { $mergedPrsJson.data.issues } elseif ($mergedPrsJson.data.pulls) { $mergedPrsJson.data.pulls } else { $mergedPrsJson.data }
$totalMerged = if ($mergedPrsJson.ok) { @($md).Count } else { 0 }
$od = if ($openPrsJson.data.issues) { $openPrsJson.data.issues } elseif ($openPrsJson.data.pulls) { $openPrsJson.data.pulls } else { $openPrsJson.data }
$totalOpenPrs = if ($openPrsJson.ok) { @($od).Count } else { 0 }
# 4. Releases
Log-Step "4/6 收集 Release 数据..."
$releasesJson = Invoke-GL @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
Log-Step "4/6 Collecting Release data..."
$releasesJson = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
$releaseCount = if ($releasesJson.ok) { @($releasesJson.data).Count } else { 0 }
# 5. CI
Log-Step "5/6 收集 CI 数据..."
$ciJson = Invoke-GL @("ci", "+builds", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
$ciBuilds = if ($ciJson.ok) { @($ciJson.data).Count } else { 0 }
Log-Step "5/6 Collecting CI data..."
$ciPipelines = Get-CIPipelines $Owner $Repo
$ciBuilds = $ciPipelines.Count
# 6. Members
Log-Step "6/6 获取贡献者..."
$membersJson = Invoke-GL @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "50")
Log-Step "6/6 Fetching contributors..."
$membersJson = Invoke-GLCheck @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "50")
$memberCount = if ($membersJson.ok) {
$d = if ($membersJson.data.members) { $membersJson.data.members } else { $membersJson.data }
if ($d -is [array]) { $d.Count } else { 0 }
@ -75,18 +77,294 @@ $hotness = [Math]::Round($starsNorm * 0.15 + $forksNorm * 0.10 + $issuesScore *
$prMergeRate = if (($totalMerged + $totalOpenPrs) -gt 0) { [Math]::Round($totalMerged / ($totalMerged + $totalOpenPrs) * 100, 1) } else { 0 }
Log-Ok "热度评分: ${hotness}/100"
Log-Info " Issues: $totalOpen 开放 / $totalClosed 关闭"
Log-Info " PR 合并率: ${prMergeRate}% | Releases: $releaseCount | 贡献者: $memberCount"
Log-Ok "Hotness score: ${hotness}/100"
Log-Info " Issues: $totalOpen open / $totalClosed closed"
Log-Info " PR merge rate: ${prMergeRate}% | Releases: $releaseCount | Contributors: $memberCount"
# Additional data for HTML report
$ciPassRate = 0
if ($ciBuilds -gt 0) {
$ciSuccess = 0
foreach ($b in $ciPipelines) {
if (Test-CISuccess $b) { $ciSuccess++ }
}
$ciPassRate = [Math]::Round($ciSuccess / $ciBuilds * 100, 1)
}
$daysSinceUpdate = 365
if ($updatedAt -and $updatedAt.Length -ge 10) {
try { $daysSinceUpdate = ((Get-Date) - [DateTime]$updatedAt.Substring(0, 10)).Days } catch { $daysSinceUpdate = 365 }
}
$recencyFactor = if ($daysSinceUpdate -le 30) { 100 } elseif ($daysSinceUpdate -le 90) { 50 } else { 10 }
$hotnessLabel = if ($hotness -ge 50) { "Hot" } elseif ($hotness -ge 30) { "Warm" } else { "Cool" }
$starsNorm = [Math]::Round([Math]::Min($stars / 1000.0, 1.0) * 100, 1)
$forksNorm = [Math]::Round([Math]::Min($forks / 200.0, 1.0) * 100, 1)
$issuesActive = [Math]::Round([Math]::Min($totalOpen / 50.0, 1.0) * 100, 1)
$prsActive = [Math]::Round([Math]::Min(($totalMerged + $totalOpenPrs) / 30.0, 1.0) * 100, 1)
$releasesActive = [Math]::Round([Math]::Min($releaseCount / 10.0, 1.0) * 100, 1)
$createdAt = if ($repoJson.data.created_at) { $repoJson.data.created_at } else { "" }
$createdAtDisplay = if ($createdAt.Length -ge 10) { $createdAt.Substring(0, 10) } else { "Unknown" }
$updatedAtDisplay = if ($updatedAt.Length -ge 10) { $updatedAt.Substring(0, 10) } else { "Unknown" }
# Detect tech stack
Log-Step "Detecting tech stack..."
$techStack = $repoLang
$researchFeatures = ""
$hasCitationCff = $false
$subJson = try { Invoke-GLCheck @("api", "GET", "/v1/$Owner/$Repo/sub_entries?ref=master") } catch { $null }
if ($subJson -and $subJson.ok -and ($subJson.data -is [array])) {
$names = @($subJson.data | ForEach-Object { if ($_.name) { $_.name } else { "" } })
$ecosystem = @()
if ($names -contains "go.mod") { $ecosystem += "Go" }
if ($names -contains "package.json") { $ecosystem += "Node.js" }
if ($names | Where-Object { $_ -match "requirements\.txt|pyproject\.toml|setup\.py|setup\.cfg|Pipfile" }) { $ecosystem += "Python" }
if ($names -contains "Cargo.toml") { $ecosystem += "Rust" }
if ($names -contains "CMakeLists.txt") { $ecosystem += "C/C++" }
if ($names | Where-Object { $_ -match "pom\.xml|build\.gradle" }) { $ecosystem += "Java/Kotlin" }
if ($names -contains "CITATION.cff") { $hasCitationCff = $true; $ecosystem += "+CITATION.cff" }
if ($ecosystem.Count -gt 0) { $techStack = ($ecosystem -join " ") }
if ($names | Where-Object { $_ -match "Dockerfile|docker-compose" }) { $researchFeatures += " Docker" }
if ($names | Where-Object { $_ -match "^data/|^datasets/" }) { $researchFeatures += " dataset" }
if ($names | Where-Object { $_ -match "\.ipynb$" }) { $researchFeatures += " Jupyter" }
if ($names | Where-Object { $_ -match "^scripts/|^experiments/" }) { $researchFeatures += " experiment-scripts" }
}
Log-Info " Tech stack: $techStack"
# Readme analysis
Log-Step "Reading README for project positioning..."
$readmeText = ""
try {
$readmeResult = Invoke-GLCheck @("api", "GET", "raw/$Owner/$Repo/master/README.md")
if ($readmeResult.ok) { $readmeText = if ($readmeResult.data) { $readmeResult.data } else { "" } }
} catch { $readmeText = "" }
$projectTopics = ""
$topicKeywords = @("machine learning","deep learning","neural network","NLP","computer vision",
"reinforcement learning","GAN","transformer","LLM","RAG","agent","bioinformatics","genomics",
"drug discovery","robotics","autonomous","simulation","optimization","benchmark","dataset",
"pre-trained","fine-tuning","distributed","federated","graph neural","knowledge graph",
"recommender","anomaly detection","scientific computing","HPC","quantum","climate","physics",
"chemistry")
foreach ($kw in $topicKeywords) {
if ($repoDesc -match [regex]::Escape($kw) -or $readmeText -match [regex]::Escape($kw)) {
if ($projectTopics) { $projectTopics += ", " }
$projectTopics += $kw
}
}
$doiFound = ""
if ($repoDesc -match '10\.\d{4,}/[\w.\-/]+') { $doiFound = $Matches[0] }
elseif ($readmeText -match '10\.\d{4,}/[\w.\-/]+') { $doiFound = $Matches[0] }
# ===== Generate HTML Report =====
Log-Step "Generating HTML report..."
$hotnessClass = $hotnessLabel.ToLower()
# Build tech stack tags
$techStackTags = ""
foreach ($t in ($techStack -split '\s+')) {
if ($t) { $techStackTags += "<span class=`"tag lang`">$t</span> " }
}
# Build research features tags
$researchTags = ""
foreach ($f in ($researchFeatures -split '\s+' | Where-Object { $_ })) {
$researchTags += "<span class=`"tag research`">$f</span> "
}
if (-not $researchTags) { $researchTags = "<span class=`"tag lang`">None detected</span>" }
# Build topic tags
$topicTags = ""
foreach ($t in ($projectTopics -split ', ' | Where-Object { $_ })) {
$topicTags += "<span class=`"tag lang`">$t</span> "
}
if (-not $topicTags) { $topicTags = "<span>None detected</span>" }
$issueStatus = if ($totalOpen -gt 20) { "<span class=`"tag warn`">Needs attention</span>" } else { "<span class=`"tag research`">Normal</span>" }
$prStatus = if ($prMergeRate -gt 70) { "<span class=`"tag research`">Healthy</span>" } else { "<span class=`"tag warn`">Needs improvement</span>" }
$releaseStatus = if ($releaseCount -gt 0) { "<span class=`"tag research`">Released</span>" } else { "<span class=`"tag warn`">No release</span>" }
$ciStatus = if ($ciPassRate -gt 80) { "<span class=`"tag research`">Stable</span>" } else { "<span class=`"tag warn`">Unstable</span>" }
$memberStatus = if ($memberCount -gt 3) { "<span class=`"tag research`">Active community</span>" } else { "<span class=`"tag warn`">Solo project</span>" }
$activityStatus = if ($daysSinceUpdate -le 30) { "<span class=`"tag research`">Active</span>" } else { "<span class=`"tag warn`">Inactive</span>" }
$doiRow = ""
if ($doiFound) { $doiRow = "<tr><th>DOI</th><td><a href=`"https://doi.org/$doiFound`">$doiFound</a></td></tr>" }
$htmlContent = @"
<!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 &mdash; Research Project Insight Report</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, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #283593 50%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header .subtitle { opacity: 0.85; font-size: 14px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.card .label { font-size: 12px; color: #888; text-transform: uppercase; margin-bottom: 6px; }
.card .value { font-size: 28px; font-weight: 700; }
.card .value.hot { color: #e53935; }
.card .value.warm { color: #f57c00; }
.card .value.cool { color: #1565c0; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 14px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin: 2px; }
.tag.lang { background: #e3f2fd; color: #1565c0; }
.tag.research { background: #e8f5e9; color: #2e7d32; }
.tag.warn { background: #fff3e0; color: #e65100; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>$Owner/$Repo</h1>
<div class="subtitle">Research Project Insight Report &mdash; $Today</div>
</div>
<div class="container">
<div class="cards">
<div class="card">
<div class="label">Hotness Score</div>
<div class="value $hotnessClass">$hotness</div>
<div class="label">$hotnessLabel</div>
</div>
<div class="card">
<div class="label">Stars</div>
<div class="value">$stars</div>
</div>
<div class="card">
<div class="label">Forks</div>
<div class="value">$forks</div>
</div>
<div class="card">
<div class="label">Contributors</div>
<div class="value">$memberCount</div>
</div>
<div class="card">
<div class="label">Open Issues</div>
<div class="value">$totalOpen</div>
</div>
<div class="card">
<div class="label">PR Merge Rate</div>
<div class="value">${prMergeRate}%</div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>Project Overview</h2>
<table>
<tr><th>Name</th><td>$repoName</td></tr>
<tr><th>Description</th><td>$repoDesc</td></tr>
<tr><th>Language</th><td><span class="tag lang">$repoLang</span></td></tr>
<tr><th>Tech Stack</th><td>$techStackTags</td></tr>
<tr><th>Created</th><td>$createdAtDisplay</td></tr>
<tr><th>Updated</th><td>$updatedAtDisplay ($daysSinceUpdate days ago)</td></tr>
<tr><th>Research Features</th><td>$researchTags</td></tr>
$doiRow
<tr><th>Topics</th><td>$topicTags</td></tr>
</table>
</div>
<div class="panel">
<h2>Activity Overview</h2>
<div id="activityChart" class="chart"></div>
</div>
</div>
<div class="row">
<div class="panel">
<h2>Health Indicators</h2>
<table>
<tr><th>Indicator</th><th>Value</th><th>Status</th></tr>
<tr><td>Total Issues</td><td>$totalOpen open / $totalClosed closed</td><td>$issueStatus</td></tr>
<tr><td>PR Merge Rate</td><td>${prMergeRate}%</td><td>$prStatus</td></tr>
<tr><td>Releases</td><td>$releaseCount</td><td>$releaseStatus</td></tr>
<tr><td>CI Pass Rate</td><td>${ciPassRate}% ($ciBuilds builds)</td><td>$ciStatus</td></tr>
<tr><td>Contributors</td><td>$memberCount people</td><td>$memberStatus</td></tr>
<tr><td>Activity</td><td>$daysSinceUpdate days since update</td><td>$activityStatus</td></tr>
</table>
</div>
<div class="panel">
<h2>Hotness Composition</h2>
<div id="hotnessChart" class="chart"></div>
</div>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant &mdash; $Today</div>
<script>
var hotnessChart = echarts.init(document.getElementById('hotnessChart'));
hotnessChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['45%', '75%'],
label: { formatter: '{b}\n{d}%' },
data: [
{ name: 'Stars', value: $starsNorm, itemStyle: { color: '#5470c6' } },
{ name: 'Forks', value: $forksNorm, itemStyle: { color: '#91cc75' } },
{ name: 'Issues', value: $issuesActive, itemStyle: { color: '#fac858' } },
{ name: 'PRs', value: $prsActive, itemStyle: { color: '#ee6666' } },
{ name: 'Releases', value: $releasesActive, itemStyle: { color: '#73c0de' } },
{ name: 'Recency', value: $recencyFactor, itemStyle: { color: '#fc8452' } }
]
}]
});
var activityChart = echarts.init(document.getElementById('activityChart'));
activityChart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Issues', 'PRs', 'Releases', 'CI Builds'] },
yAxis: { type: 'value' },
series: [
{ name: 'Open/In Progress', type: 'bar', data: [$totalOpen, $totalOpenPrs, 0, 0], itemStyle: { color: '#fac858' } },
{ name: 'Completed', type: 'bar', data: [$totalClosed, $totalMerged, $releaseCount, $ciBuilds], itemStyle: { color: '#91cc75' } }
]
});
</script>
</body>
</html>
"@
if (-not $DryRun) {
$htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8
Log-Ok "HTML report generated: $OutputFile"
} else {
Log-Warn "[DRY RUN] Would generate: $OutputFile"
}
# Summary output
Divider
Write-Host "====== 报告摘要 ======" -ForegroundColor White
Write-Host " 仓库: $Owner/$Repo"
Write-Host " 语言: $repoLang"
Write-Host " 热度评分: $hotness/100"
Write-Host " Issues: $totalOpen 开放 / $totalClosed 关闭"
Write-Host " PR 合并率: ${prMergeRate}%"
Write-Host " 贡献者: $memberCount"
Write-Host "====== Analysis Summary ======" -ForegroundColor White
Write-Host " Repo: $Owner/$Repo"
Write-Host " Language: $repoLang"
Write-Host " Tech Stack: $techStack"
Write-Host " Hotness: $hotness/100 ($hotnessLabel)"
Write-Host " Issues: $totalOpen open / $totalClosed closed"
Write-Host " PR merge: ${prMergeRate}%"
Write-Host " CI pass: ${ciPassRate}%"
Write-Host " Members: $memberCount"
Write-Host " Features: $researchFeatures"
if ($doiFound) { Write-Host " DOI: $doiFound" }
Write-Host " Report: $OutputFile"
Divider
Log-Ok "分析完成"
Log-Ok "Analysis complete"

View File

@ -1,11 +1,11 @@
# GitLink 科研辅助 — 场景 3合规与复现性检查 (PowerShell)
# GitLink Research - Scenario 3: Compliance & Reproducibility Check (PowerShell)
param(
[string]$Owner, [string]$Repo, [string]$LocalPath = ".",
[string]$Output = "", [switch]$NoWiki, [switch]$DryRun
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module "$ScriptDir\lib\common.psm1" -Force
Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue
$ErrorActionPreference = "Continue"
Check-Auth
@ -14,8 +14,16 @@ $Owner = $resolved.Owner; $Repo = $resolved.Repo
$Today = Get-Date -Format "yyyy-MM-dd"
$OutputDir = Join-Path $ScriptDir "..\output"
if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null }
$OutputFile = if ($Output) { $Output } else { Join-Path $OutputDir "reproducibility-${Repo}-${Today}.html" }
Log-Title "GitLink 科研辅助 — 合规与复现性检查"
Log-Title "GitLink Research - Compliance & Reproducibility"
# Get repo metadata for description and DOI detection
Log-Step "Fetching repo metadata..."
$repoJson = Invoke-GLCheck @("repo", "+info", "--owner", $Owner, "--repo", $Repo)
$repoDesc = if ($repoJson.data.description) { $repoJson.data.description } else { "" }
$doiFound = ""
if ($repoDesc -match '10\.\d{4,}/[\w.\-/]+') { $doiFound = $Matches[0] }
# Scoring: 0, 0.5, 1.0 per dimension
$dLicense = @{Score=0; Detail=""}; $dNoSecret = @{Score=0; Detail=""}
@ -23,91 +31,205 @@ $dReadme = @{Score=0; Detail=""}; $dDeps = @{Score=0; Detail=""}
$dBuild = @{Score=0; Detail=""}; $dCI = @{Score=0; Detail=""}
$dTest = @{Score=0; Detail=""}; $dData = @{Score=0; Detail=""}
# 1. Compliance scan
Log-Step "1/7 合规扫描..."
if (Test-Path (Join-Path $LocalPath ".git")) {
Push-Location $LocalPath
$compResult = Invoke-GL @("compliance", "+scan")
Pop-Location
if ($compResult.ok) {
$licenseOk = $compResult.data.license.status ?? ""
if ($licenseOk -match "ok|clean|found") { $dLicense.Score = 1.0; $dLicense.Detail = "检测到合规许可证" }
elseif ($licenseOk -eq "warning") { $dLicense.Score = 0.5; $dLicense.Detail = "有许可证但非标准" }
else { $dLicense.Score = 0; $dLicense.Detail = "未检测到 LICENSE" }
# -- Helper: ensure local clone of target repo for compliance scan --
function Ensure-LocalClone {
param([string]$Owner, [string]$Repo, [string]$LocalPath)
$secCount = @($compResult.data.secrets.findings ?? @()).Count
$piiCount = @($compResult.data.exposure.findings ?? @()).Count
if ($secCount -eq 0 -and $piiCount -eq 0) { $dNoSecret.Score = 1.0; $dNoSecret.Detail = "未发现密钥/PII" }
elseif ($secCount + $piiCount -le 3) { $dNoSecret.Score = 0.5; $dNoSecret.Detail = "发现少量可疑项" }
else { $dNoSecret.Score = 0; $dNoSecret.Detail = "发现多处密钥/PII泄露" }
$result = @{ ScanPath = $null; NeedCleanup = $false }
# Check if current directory is already a clone of the target repo
if (Test-Path (Join-Path $LocalPath ".git")) {
Push-Location $LocalPath
try {
$remoteUrl = git remote get-url origin 2>$null
if ($remoteUrl -and ($remoteUrl -match [regex]::Escape("$Owner/$Repo"))) {
Pop-Location
Log-Info "Local path is already a clone of $Owner/$Repo"
$result.ScanPath = $LocalPath
return $result
}
} catch { }
Pop-Location
}
} else { Log-Warn "本地仓库路径无 .git跳过合规扫描" }
# Clone target repo to temp directory for compliance scan
$suffix = [System.Guid]::NewGuid().ToString().Substring(0, 8)
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "gl-compliance-${Repo}-${suffix}"
Log-Info "Cloning $Owner/$Repo to temp directory for compliance scan..."
$cloneUrl = if ($env:GITLINK_TOKEN) {
"https://oauth2:$env:GITLINK_TOKEN@gitlink.org.cn/$Owner/$Repo.git"
} else {
"https://gitlink.org.cn/$Owner/$Repo.git"
}
$cloneOutput = git clone --depth 1 $cloneUrl $tempDir 2>&1
if ($LASTEXITCODE -ne 0) {
Log-Warn "HTTPS clone failed, trying SSH..."
$sshUrl = "git@gitlink.org.cn:$Owner/$Repo.git"
$cloneOutput = git clone --depth 1 $sshUrl $tempDir 2>&1
if ($LASTEXITCODE -ne 0) {
Log-Warn "Clone failed for both HTTPS and SSH. Skipping compliance scan."
Log-Warn "Reason: $($cloneOutput -join '; ')"
return $result
}
}
Log-Ok "Cloned successfully to $tempDir"
$result.ScanPath = $tempDir
$result.NeedCleanup = $true
return $result
}
# 1. Compliance scan + build file inventory from local clone
Log-Step "1/7 Compliance scan..."
$cloneInfo = Ensure-LocalClone -Owner $Owner -Repo $Repo -LocalPath $LocalPath
$scanPath = $cloneInfo.ScanPath
# Build a recursive relative file list ($names) and load README text from the
# local clone. The remote `raw/...` and `/v1/.../sub_entries` content endpoints
# are NOT valid API routes on this GitLink server (they return the SPA HTML),
# so all file-based detection (README, deps, build, tests, license) reads from
# the clone that Ensure-LocalClone already provides.
$names = @()
$readmeText = ""
if ($scanPath -and (Test-Path $scanPath)) {
Push-Location $scanPath
try {
$names = @(Get-ChildItem -Recurse -File -Force -ErrorAction SilentlyContinue -Name |
Where-Object { $_ -notmatch '^\.git[\\/]' -and $_ -notmatch '[\\/]node_modules[\\/]' } |
ForEach-Object { ($_ -replace '\\', '/') })
foreach ($rn in @("README.md","README.MD","README","README.rst","README.txt","README.zh-CN.md","readme.md")) {
if (Test-Path $rn) { $readmeText = Get-Content -Raw -Path $rn -Encoding UTF8 -ErrorAction SilentlyContinue; break }
}
} catch { }
Pop-Location
} else {
# Fallback: no local clone — use the working entries endpoint for a root file list
$defaultBranch = if ($repoJson.data.default_branch) { $repoJson.data.default_branch } else { "master" }
$entriesRes = Invoke-GLCheck @("api", "GET", "/$Owner/$Repo/entries?ref=$defaultBranch")
if ($entriesRes.ok -and $entriesRes.data.entries) {
$names = @($entriesRes.data.entries | ForEach-Object { if ($_.name) { ($_.name -replace '\\','/') } else { "" } })
}
}
# Secrets / PII via compliance +scan (runs inside the clone)
if ($scanPath -and (Test-Path (Join-Path $scanPath ".git"))) {
Push-Location $scanPath
$compResult = Invoke-GLCheck @("compliance", "+scan")
Pop-Location
# NOTE: compliance +scan has NO license module (modules = secrets/exposure/vocab),
# and findings are a FLAT array with a .module field — not nested under
# .data.secrets/.data.exposure. License is detected separately below.
if ($compResult.ok -and $compResult.data.findings) {
$allFindings = @($compResult.data.findings)
$secCount = @($allFindings | Where-Object { $_.module -eq 'secrets' }).Count
$piiCount = @($allFindings | Where-Object { $_.module -eq 'exposure' }).Count
$totalFindings = $secCount + $piiCount
if ($totalFindings -eq 0) { $dNoSecret.Score = 1.0; $dNoSecret.Detail = "No secrets/PII found" }
elseif ($totalFindings -le 3) { $dNoSecret.Score = 0.5; $dNoSecret.Detail = "Few suspicious items ($totalFindings)" }
else { $dNoSecret.Score = 0; $dNoSecret.Detail = "Multiple secret/PII leaks ($totalFindings)" }
} else {
$dNoSecret.Score = 1.0; $dNoSecret.Detail = "No secrets/PII found"
}
} else {
Log-Warn "Cannot access repo for compliance scan, skipping secrets scan"
$dNoSecret.Detail = "Not scanned (repo inaccessible)"
}
# License: detected from file inventory (compliance scanner has no license module)
$licenseFile = $names | Where-Object { $_ -match '(^|/)(LICENSE|LICENCE|COPYING|UNLICENCE)(\.(md|txt))?$' } | Select-Object -First 1
if ($licenseFile) { $dLicense.Score = 1.0; $dLicense.Detail = "License file found: $licenseFile" }
elseif ($readmeText -match 'MIT License|Apache License|GPL|BSD') { $dLicense.Score = 0.5; $dLicense.Detail = "License mentioned in README" }
else { $dLicense.Score = 0; $dLicense.Detail = "No LICENSE file" }
# 2. README
Log-Step "2/7 README 完整性..."
try {
$readmeResult = Invoke-GL @("api", "GET", "raw/$Owner/$Repo/master/README.md")
if ($readmeResult.ok) { $readmeText = $readmeResult.data ?? "" } else { $readmeText = "" }
} catch { $readmeText = "" }
Log-Step "2/7 README completeness..."
# README text was already loaded from the local clone in step 1.
$sectionCount = 0
foreach ($kw in @("# ", "## ", "Install", "Usage", "License", "Contribut", "Citation")) {
if ($readmeText -match $kw) { $sectionCount++ }
}
if ($sectionCount -ge 5) { $dReadme.Score = 1.0 }
elseif ($sectionCount -ge 3) { $dReadme.Score = 0.5 }
else { $dReadme.Score = 0 }
$dReadme.Detail = "README 章节数: $sectionCount"
Log-Info " $($dReadme.Detail)"
if ($sectionCount -ge 5) { $dReadme.Score = 1.0; $dReadme.Detail = "README complete, $sectionCount sections" }
elseif ($sectionCount -ge 3) { $dReadme.Score = 0.5; $dReadme.Detail = "README partial, $sectionCount sections" }
else { $dReadme.Score = 0; $dReadme.Detail = "README missing or too short" }
Log-Info " README sections: $sectionCount"
# 3. Dependencies
Log-Step "3/7 依赖声明..."
$subResult = Invoke-GL @("api", "GET", "/v1/$Owner/$Repo/sub_entries?ref=master")
Log-Step "3/7 Dependency declaration..."
# $names (relative file paths from the clone) was built in step 1.
$depFiles = 0
if ($subResult.ok) {
$names = @($subResult.data | ForEach-Object { $_.name ?? "" })
foreach ($df in @("package.json","go.mod","requirements.txt","pyproject.toml","Cargo.toml","CMakeLists.txt","pom.xml","build.gradle")) {
if ($names -contains $df) { $depFiles++ }
}
$depList = ""
foreach ($df in @("package.json","go.mod","requirements.txt","pyproject.toml","setup.py","setup.cfg","Cargo.toml","CMakeLists.txt","pom.xml","build.gradle","Gemfile","Makefile")) {
if ($names | Where-Object { (Split-Path $_ -Leaf) -eq $df }) { $depFiles++; $depList += "$df, " }
}
if ($depFiles -ge 1) { $dDeps.Score = 1.0 }
elseif ($readmeText -match "dependenc|requirement|依赖|install") { $dDeps.Score = 0.5 }
else { $dDeps.Score = 0 }
$dDeps.Detail = "依赖文件数: $depFiles"
if ($depFiles -ge 1) { $dDeps.Score = 1.0; $dDeps.Detail = "Standard dep file(s): $depList".TrimEnd(', ') }
elseif ($readmeText -match "dependenc|requirement|install") { $dDeps.Score = 0.5; $dDeps.Detail = "Deps mentioned in README" }
else { $dDeps.Score = 0; $dDeps.Detail = "No dependency declaration" }
Log-Info " Dep files: $depFiles"
# 4. Build
Log-Step "4/7 构建说明..."
Log-Step "4/7 Build instructions..."
$buildScore = 0
if ($readmeText -match "build|install|compile|make|构建|安装|编译") { $buildScore++ }
if ($subResult.ok -and (@($subResult.data | Where-Object { $_.name -match "Makefile|Dockerfile" })).Count -gt 0) { $buildScore++ }
if ($buildScore -ge 2) { $dBuild.Score = 1.0 } elseif ($buildScore -ge 1) { $dBuild.Score = 0.5 } else { $dBuild.Score = 0 }
$dBuild.Detail = "构建说明得分: $buildScore/2"
if ($readmeText -match "build|install|compile|make|run") { $buildScore++ }
if ($names | Where-Object { $_ -match "Makefile|Dockerfile|docker-compose" }) { $buildScore++ }
if ($names | Where-Object { $_ -match "\.github/workflows|\.gitlab-ci|Jenkinsfile" }) { $buildScore++ }
if ($buildScore -ge 3) { $dBuild.Score = 1.0; $dBuild.Detail = "Detailed build docs + automation" }
elseif ($buildScore -ge 1) { $dBuild.Score = 0.5; $dBuild.Detail = "Partial build instructions" }
else { $dBuild.Score = 0; $dBuild.Detail = "No build instructions" }
Log-Info " Build score: $buildScore/3"
# 5. CI
Log-Step "5/7 CI 配置..."
$ciResult = Invoke-GL @("ci", "+builds", "--owner", $Owner, "--repo", $Repo, "--limit", "10")
$ciBuilds = if ($ciResult.ok) { @($ciResult.data).Count } else { 0 }
if ($ciBuilds -gt 0) { $dCI.Score = 1.0; $dCI.Detail = "CI 已配置 ($ciBuilds 次构建)" }
else { $dCI.Score = 0; $dCI.Detail = "无 CI 配置" }
Log-Step "5/7 CI configuration..."
$ciPipelines = Get-CIPipelines $Owner $Repo
$ciBuilds = $ciPipelines.Count
$ciOk = 0
foreach ($b in $ciPipelines) {
if (Test-CISuccess $b) { $ciOk++ }
}
if ($ciBuilds -gt 0 -and $ciOk -ge 1) { $dCI.Score = 1.0; $dCI.Detail = "CI configured & passing ($ciOk/$ciBuilds)" }
elseif ($ciBuilds -gt 0) { $dCI.Score = 0.5; $dCI.Detail = "CI exists but failing" }
else { $dCI.Score = 0; $dCI.Detail = "No CI configured" }
Log-Info " CI builds: $ciBuilds"
# 6. Tests
Log-Step "6/7 测试证据..."
Log-Step "6/7 Test evidence..."
$testScore = 0
if ($subResult.ok) {
if (@($subResult.data | Where-Object { $_.name -match "^test/|^tests/|^spec/" }).Count -gt 0) { $testScore++ }
if (@($subResult.data | Where-Object { $_.name -match "_test\.|\.test\.|_spec\." }).Count -gt 0) { $testScore++ }
}
if ($readmeText -match "test|测试|validate") { $testScore++ }
if ($testScore -ge 3) { $dTest.Score = 1.0 } elseif ($testScore -ge 1) { $dTest.Score = 0.5 } else { $dTest.Score = 0 }
$dTest.Detail = "测试证据得分: $testScore/3"
if ($names | Where-Object { $_ -match "^test/|^tests/|^spec/|^__tests__/" }) { $testScore++ }
if ($names | Where-Object { $_ -match "_test\.|\.test\.|_spec\.|\.spec\.|test_" }) { $testScore++ }
if ($readmeText -match "test|validate") { $testScore++ }
if ($testScore -ge 3) { $dTest.Score = 1.0; $dTest.Detail = "Test dir + files + instructions" }
elseif ($testScore -ge 1) { $dTest.Score = 0.5; $dTest.Detail = "Partial test evidence" }
else { $dTest.Score = 0; $dTest.Detail = "No test evidence" }
Log-Info " Test evidence: $testScore/3"
# 7. Data
Log-Step "7/7 数据可用性..."
Log-Step "7/7 Data availability..."
$dataScore = 0
if ($readmeText -match "dataset|data/|数据|zenodo|figshare|kaggle") { $dataScore++ }
if ($readmeText -match "10\.\d{4,}/[\w.\-/]+") { $dataScore++ }
if ($dataScore -ge 2) { $dData.Score = 1.0 } elseif ($dataScore -ge 1) { $dData.Score = 0.5 } else { $dData.Score = 0 }
$dData.Detail = "数据声明得分: $dataScore/2"
$dataEvidence = ""
if ($readmeText -match "dataset|data/|zenodo|figshare|kaggle|huggingface") { $dataScore++; $dataEvidence = "keyword found" }
if ($readmeText -match "https?://[^\s]+(?:zenodo|figshare|data\.|dataset)") { $dataScore++; $dataEvidence += ", data link found" }
if ($doiFound) { $dataScore++; $dataEvidence += ", DOI/article found" }
if ($dataScore -ge 2) { $dData.Score = 1.0; $dData.Detail = "Clear data statement: $dataEvidence" }
elseif ($dataScore -ge 1) { $dData.Score = 0.5; $dData.Detail = "Partial data statement: $dataEvidence" }
else { $dData.Score = 0; $dData.Detail = "No data availability statement" }
Log-Info " Data score: $dataScore/3"
# Cleanup temp clone (deferred until after all file-based checks are done)
if ($cloneInfo -and $cloneInfo.NeedCleanup -and $cloneInfo.ScanPath -and (Test-Path $cloneInfo.ScanPath)) {
Get-ChildItem -Path $cloneInfo.ScanPath -Recurse -Force -ErrorAction SilentlyContinue |
ForEach-Object { $_.Attributes = 'Normal' }
Remove-Item -Recurse -Force $cloneInfo.ScanPath -ErrorAction SilentlyContinue
if (Test-Path $cloneInfo.ScanPath) {
cmd /c "rd /s /q `"$($cloneInfo.ScanPath)`"" 2>$null
}
if (-not (Test-Path $cloneInfo.ScanPath)) { Log-Info "Cleaned up temp clone." }
else { Log-Warn "Could not fully remove temp clone: $($cloneInfo.ScanPath)" }
}
# Total score
$weights = @(0.15, 0.15, 0.15, 0.15, 0.10, 0.10, 0.10, 0.10)
@ -117,15 +239,181 @@ for ($i = 0; $i -lt 8; $i++) { $totalScore += $scores[$i] * $weights[$i] }
$totalScore = [Math]::Round($totalScore * 100, 1)
$grade = if ($totalScore -ge 85) { "A" } elseif ($totalScore -ge 70) { "B" } elseif ($totalScore -ge 55) { "C" } elseif ($totalScore -ge 40) { "D" } else { "F" }
$gradeLabel = if ($grade -eq "A") { "Excellent - highly reproducible" } elseif ($grade -eq "B") { "Good - largely reproducible" } elseif ($grade -eq "C") { "Fair - partially reproducible" } elseif ($grade -eq "D") { "Poor - difficult to reproduce" } else { "Fail - barely reproducible" }
$gradeColor = if ($grade -eq "A") { "#2e7d32" } elseif ($grade -eq "B") { "#558b2f" } elseif ($grade -eq "C") { "#f57c00" } elseif ($grade -eq "D") { "#e65100" } else { "#c62828" }
Divider
Write-Host "====== 复现性评分卡 ======" -ForegroundColor White
Write-Host " 综合评分: $totalScore/100 — $grade"
$dims = @("许可证","无密钥/PII","README","依赖","构建","CI","测试","数据")
# Build recommendation strings
$recLicense = if ($dLicense.Score -ne 1.0) { "Add MIT/Apache-2.0/GPL-3.0 license file" } else { "-" }
$recSecret = if ($dNoSecret.Score -ne 1.0) { "Remove leaked secrets, use environment variables" } else { "-" }
$recReadme = if ($dReadme.Score -ne 1.0) { "Add purpose, install, usage, license, citation sections" } else { "-" }
$recDeps = if ($dDeps.Score -ne 1.0) { "Add package.json/go.mod/requirements.txt etc." } else { "-" }
$recBuild = if ($dBuild.Score -ne 1.0) { "Add Makefile/Dockerfile + build steps in README" } else { "-" }
$recCI = if ($dCI.Score -ne 1.0) { "Configure GitLink CI or GitHub Actions" } else { "-" }
$recTest = if ($dTest.Score -ne 1.0) { "Add unit/integration tests, document test commands" } else { "-" }
$recData = if ($dData.Score -ne 1.0) { "Document dataset sources, provide Zenodo/Figshare link" } else { "-" }
# Icon helpers
function ScoreIcon($s) {
if ($s -eq 1.0) { return "&#9989;" }
elseif ($s -eq 0.5) { return "&#9888;&#65039;" }
else { return "&#10060;" }
}
function ScorePercent($s) { [Math]::Round($s * 100) }
# ===== Generate HTML Report =====
Log-Step "Generating HTML scorecard..."
$dims = @("License","No Secrets/PII","README","Dependencies","Build","CI","Tests","Data")
$dets = @($dLicense, $dNoSecret, $dReadme, $dDeps, $dBuild, $dCI, $dTest, $dData)
$recs = @($recLicense, $recSecret, $recReadme, $recDeps, $recBuild, $recCI, $recTest, $recData)
$pcts = @(0.15, 0.15, 0.15, 0.15, 0.10, 0.10, 0.10, 0.10)
# Build dimension table rows
$dimRows = ""
for ($i = 0; $i -lt 8; $i++) {
$icon = ScoreIcon $scores[$i]
$pct = ScorePercent $scores[$i]
$suggestion = $recs[$i]
$detail = $dets[$i].Detail
$name = $dims[$i]
$dimRows += @"
<tr>
<td>$name</td>
<td>$icon</td>
<td>$detail</td>
<td>$suggestion</td>
</tr>
"@
}
$htmlContent = @"
<!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 &mdash;Reproducibility Scorecard</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, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.row { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
.grade-circle { text-align: center; padding: 20px; }
.grade-letter { font-size: 72px; font-weight: 900; color: $gradeColor; }
.grade-score { font-size: 24px; color: #888; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>$Owner/$Repo &mdash;Research Reproducibility Scorecard</h1>
<div style="opacity:0.8;font-size:14px;">$Today</div>
</div>
<div class="container">
<div class="row">
<div class="panel grade-circle">
<div class="grade-letter">$grade</div>
<div class="grade-score">$totalScore / 100</div>
<div style="margin-top:12px;color:#888;">$gradeLabel</div>
</div>
<div class="panel">
<h2>Radar Chart</h2>
<div id="radarChart" class="chart"></div>
</div>
<div class="panel">
<h2>Dimension Scores</h2>
<table>
<tr><th>Dimension</th><th>Score</th><th>Weight</th></tr>
<tr><td>License</td><td>$(ScorePercent $dLicense.Score)%</td><td>15%</td></tr>
<tr><td>No Secrets/PII</td><td>$(ScorePercent $dNoSecret.Score)%</td><td>15%</td></tr>
<tr><td>README</td><td>$(ScorePercent $dReadme.Score)%</td><td>15%</td></tr>
<tr><td>Dependencies</td><td>$(ScorePercent $dDeps.Score)%</td><td>15%</td></tr>
<tr><td>Build</td><td>$(ScorePercent $dBuild.Score)%</td><td>10%</td></tr>
<tr><td>CI</td><td>$(ScorePercent $dCI.Score)%</td><td>10%</td></tr>
<tr><td>Tests</td><td>$(ScorePercent $dTest.Score)%</td><td>10%</td></tr>
<tr><td>Data</td><td>$(ScorePercent $dData.Score)%</td><td>10%</td></tr>
</table>
</div>
</div>
<div class="panel">
<h2>Detailed Assessment &amp; Recommendations</h2>
<table>
<tr><th>Dimension</th><th>Rating</th><th>Evidence</th><th>Suggestion</th></tr>
$dimRows
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant &mdash;$Today</div>
<script>
var radarChart = echarts.init(document.getElementById('radarChart'));
radarChart.setOption({
radar: {
indicator: [
{ name: 'License', max: 100 },
{ name: 'No Secrets', max: 100 },
{ name: 'README', max: 100 },
{ name: 'Deps', max: 100 },
{ name: 'Build', max: 100 },
{ name: 'CI', max: 100 },
{ name: 'Tests', max: 100 },
{ name: 'Data', max: 100 }
],
center: ['50%', '55%'],
radius: '70%'
},
series: [{
type: 'radar',
data: [{
value: [
$(ScorePercent $dLicense.Score),
$(ScorePercent $dNoSecret.Score),
$(ScorePercent $dReadme.Score),
$(ScorePercent $dDeps.Score),
$(ScorePercent $dBuild.Score),
$(ScorePercent $dCI.Score),
$(ScorePercent $dTest.Score),
$(ScorePercent $dData.Score)
],
name: 'Reproducibility',
areaStyle: { color: 'rgba(57,73,171,0.3)' },
lineStyle: { color: '#3949ab' }
}]
}]
});
</script>
</body>
</html>
"@
if (-not $DryRun) {
$htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8
Log-Ok "HTML scorecard generated: $OutputFile"
} else {
Log-Warn "[DRY RUN] Would generate: $OutputFile"
}
# Console summary
Divider
Write-Host "====== Reproducibility Scorecard ======" -ForegroundColor White
Write-Host " Total score: $totalScore/100 [$grade] ($gradeLabel)"
$dimNames = @("License","No Secrets/PII","README","Dependencies","Build","CI","Tests","Data")
for ($i = 0; $i -lt 8; $i++) {
$icon = if ($scores[$i] -eq 1.0) { "OK" } elseif ($scores[$i] -eq 0.5) { "~" } else { "!!" }
Write-Host " $icon $($dims[$i]) ($([Math]::Round($scores[$i]*100))%): $($dets[$i].Detail)"
Write-Host " $icon $($dimNames[$i]) ($(ScorePercent $scores[$i])%): $($dets[$i].Detail)"
}
Write-Host " Report: $OutputFile"
Divider
Log-Ok "检查完成"
Log-Ok "Check complete"

View File

@ -1,4 +1,4 @@
# GitLink 科研辅助 — 场景 5进度跟踪与预警 (PowerShell)
# GitLink Research - Scenario 5: Progress Tracking & Alerting (PowerShell)
param(
[string]$Owner, [string]$Repo, [string]$Org = "",
[int]$Weeks = 4, [string]$Output = "",
@ -6,7 +6,7 @@ param(
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module "$ScriptDir\lib\common.psm1" -Force
Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue
$ErrorActionPreference = "Continue"
Check-Auth
@ -15,56 +15,264 @@ else { $resolved = Resolve-OwnerRepo -Owner $Owner -Repo $Repo; $Owner = $resolv
$Today = Get-Date -Format "yyyy-MM-dd"
$OutputDir = Join-Path $ScriptDir "..\output"
if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null }
$OutputFile = if ($Output) { $Output } else { Join-Path $OutputDir "progress-weekly-${Owner}-${Today}.html" }
Log-Title "GitLink 科研辅助 — 进度跟踪与预警"
Log-Title "GitLink Research - Progress Tracking"
# 1. Issues
Log-Step "1/4 Issue 数据..."
$openIssues = Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100")
$closedIssues = Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100")
$totalOpen = if ($openIssues.ok) { @($openIssues.data.issues ?? $openIssues.data).Count } else { 0 }
$totalClosed = if ($closedIssues.ok) { @($closedIssues.data.issues ?? $closedIssues.data).Count } else { 0 }
Log-Step "1/5 Collecting Issue data..."
$openIssues = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100")
$closedIssues = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100")
$oiData = if ($openIssues.data.issues) { $openIssues.data.issues } else { $openIssues.data }
$totalOpen = if ($openIssues.ok) { @($oiData).Count } else { 0 }
$ciData = if ($closedIssues.data.issues) { $closedIssues.data.issues } else { $closedIssues.data }
$totalClosed = if ($closedIssues.ok) { @($ciData).Count } else { 0 }
Log-Info " Issues: $totalOpen open / $totalClosed closed"
# 2. PRs
Log-Step "2/4 PR 数据..."
$mergedPrs = Invoke-GL @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100")
$openPrs = Invoke-GL @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50")
$totalMerged = if ($mergedPrs.ok) { @($mergedPrs.data.issues ?? $mergedPrs.data.pulls ?? $mergedPrs.data).Count } else { 0 }
$totalOpenPrs = if ($openPrs.ok) { @($openPrs.data.issues ?? $openPrs.data.pulls ?? $openPrs.data).Count } else { 0 }
Log-Step "2/5 Collecting PR data..."
$mergedPrs = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100")
$openPrs = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50")
$mData = if ($mergedPrs.data.issues) { $mergedPrs.data.issues } elseif ($mergedPrs.data.pulls) { $mergedPrs.data.pulls } else { $mergedPrs.data }
$totalMerged = if ($mergedPrs.ok) { @($mData).Count } else { 0 }
$opData = if ($openPrs.data.issues) { $openPrs.data.issues } elseif ($openPrs.data.pulls) { $openPrs.data.pulls } else { $openPrs.data }
$totalOpenPrs = if ($openPrs.ok) { @($opData).Count } else { 0 }
Log-Info " PRs: $totalOpenPrs open / $totalMerged merged"
# 3. Releases & CI
Log-Step "3/4 Release & CI..."
$releases = Invoke-GL @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
# 3. Releases
Log-Step "3/5 Collecting Release data..."
$releases = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
$releaseCount = if ($releases.ok) { @($releases.data).Count } else { 0 }
$ci = Invoke-GL @("ci", "+builds", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
$ciTotal = if ($ci.ok) { @($ci.data).Count } else { 0 }
$lastReleaseDate = ""
if ($releases.ok -and $releaseCount -gt 0) {
$relData = if ($releases.data.releases) { $releases.data.releases } else { $releases.data }
if ($relData -is [array] -and $relData.Count -gt 0) {
$lastReleaseDate = if ($relData[0].created_at) { $relData[0].created_at } else { "" }
if ($lastReleaseDate.Length -ge 10) { $lastReleaseDate = $lastReleaseDate.Substring(0, 10) }
}
}
Log-Info " Releases: $releaseCount"
# 4. Health score
Log-Step "4/4 健康评分..."
$iv = [Math]::Min($totalClosed / ($Weeks * 7), 1.0)
$pr = if (($totalMerged + $totalOpenPrs) -gt 0) { $totalMerged / ($totalMerged + $totalOpenPrs) } else { 0 }
# 4. CI
Log-Step "4/5 Collecting CI data..."
$ciPipelines = Get-CIPipelines $Owner $Repo
$ciTotal = $ciPipelines.Count
$ciOk = 0
foreach ($b in $ciPipelines) {
if (Test-CISuccess $b) { $ciOk++ }
}
Log-Info " CI: $ciOk/$ciTotal passed"
# 5. Health score
Log-Step "5/5 Calculating health score and anomaly detection..."
$prevClosed = [Math]::Floor($totalClosed / 2)
# Issue velocity (normalized)
$iv = [Math]::Min($totalClosed / ($Weeks * 7.0), 1.0)
# PR merge rate
$prMR = if (($totalMerged + $totalOpenPrs) -gt 0) { $totalMerged / ($totalMerged + $totalOpenPrs) } else { 0 }
# Release cadence
$rc = if ($releaseCount -ge 3) { 1.0 } elseif ($releaseCount -ge 1) { 0.5 } else { 0.2 }
$health = [Math]::Round(($iv * 0.30 + $pr * 0.25 + $rc * 0.25 + [Math]::Min(($totalClosed * 0.01), 1.0) * 0.10 + 0.5 * 0.10) * 100, 1)
# CI pass rate
$ciScore = if ($ciTotal -gt 0) { $ciOk / $ciTotal } else { 0 }
# Activity trend
$diff = if ($prevClosed -gt 0) { ($totalClosed - $prevClosed) / $prevClosed } else { 0 }
$trend = [Math]::Min([Math]::Max($diff + 0.5, 0.0), 1.0)
$health = [Math]::Round(($iv * 0.30 + $prMR * 0.25 + $rc * 0.25 + $ciScore * 0.10 + $trend * 0.10) * 100, 1)
# Anomalies
$anomalies = @()
if ($totalOpen -gt 20) { $anomalies += "[Warning] 开放 Issue 数量($totalOpen)偏高" }
if ($totalOpenPrs -gt 5) { $anomalies += "[Warning] $totalOpenPrs 个开放 PR 积压" }
if ($releaseCount -eq 0) { $anomalies += "[Info] 无 Release 记录" }
if ($totalOpen -gt $totalClosed) { $anomalies += "[Warning] Issue 积压(开放 > 关闭)" }
if ($totalOpen -gt 20) { $anomalies += @{type="stalled_issue"; severity="Warning"; detail="Open issues ($totalOpen) high, may be stalled"} }
if ($totalOpenPrs -gt 5) { $anomalies += @{type="pr_bottleneck"; severity="Warning"; detail="$totalOpenPrs open PRs backlogged"} }
if ($releaseCount -eq 0) { $anomalies += @{type="no_release"; severity="Info"; detail="No release record"} }
if ($prevClosed -gt 0 -and $totalClosed -lt $prevClosed * 0.5) { $anomalies += @{type="activity_decline"; severity="Warning"; detail="Activity dropped >50%"} }
if ($ciTotal -gt 0 -and $ciOk / $ciTotal -lt 0.5) { $anomalies += @{type="ci_failure"; severity="Warning"; detail="CI pass rate <50%"} }
$anomalyCount = $anomalies.Count
$label = if ($health -ge 80) { "健康" } elseif ($health -ge 60) { "正常" } elseif ($health -ge 40) { "需关注" } else { "风险" }
$healthLabel = if ($health -ge 80) { "Healthy" } elseif ($health -ge 60) { "Normal" } elseif ($health -ge 40) { "Needs Attention" } else { "At Risk" }
$healthColor = if ($health -ge 80) { "#2e7d32" } elseif ($health -ge 60) { "#558b2f" } elseif ($health -ge 40) { "#f57c00" } else { "#c62828" }
Log-Ok "Health score: $health/100 ($healthLabel)"
if ($anomalyCount -gt 0) { Log-Warn "Detected $anomalyCount anomalies" }
# ===== Generate HTML Report =====
Log-Step "Generating weekly HTML report..."
# Build anomaly table rows
$anomalyRows = ""
if ($anomalyCount -gt 0) {
foreach ($a in $anomalies) {
$sevClass = if ($a.severity -eq "Critical") { "sev-Critical" } elseif ($a.severity -eq "Warning") { "sev-Warning" } else { "sev-Info" }
$anomalyRows += "<tr><td>$($a.type)</td><td class=`"$sevClass`">$($a.severity)</td><td>$($a.detail)</td></tr>`n"
}
}
$issueTrend = if ($totalClosed -gt $totalOpen) { "<span class=`"ok`">Improving</span>" } else { "<span class=`"warn`">Backlog growing</span>" }
$issueAdvice = if ($totalOpen -gt $totalClosed) { "Schedule an Issue cleanup day" } else { "-" }
$prTrend = if ($totalOpenPrs -le 5) { "<span class=`"ok`">Normal</span>" } else { "<span class=`"warn`">Backlogged</span>" }
$prAdvice = if ($totalOpenPrs -gt 5) { "Increase code review frequency" } else { "-" }
$releaseTrend = if ($releaseCount -ge 3) { "<span class=`"ok`">Active</span>" } else { "<span class=`"warn`">Inactive</span>" }
$releaseAdvice = if ($releaseCount -eq 0) { "Recommend publishing v0.1.0" } else { "-" }
$ciPassRate = if ($ciTotal -gt 0) { [Math]::Round($ciOk / $ciTotal * 100) } else { 0 }
$ciTrend = if ($ciTotal -gt 0 -and $ciPassRate -ge 80) { "<span class=`"ok`">Stable</span>" } else { "<span class=`"warn`">Needs improvement</span>" }
$ciAdvice = if ($ciTotal -eq 0) { "Configure GitLink CI" } else { "-" }
$lastRelDisplay = if ($lastReleaseDate) { $lastReleaseDate } else { "None" }
$lastRelTrend = if ($lastReleaseDate) { "<span class=`"ok`">Released</span>" } else { "<span class=`"warn`">No record</span>" }
$htmlContent = @"
<!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 &mdash;Progress Weekly Report $Today</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, sans-serif; background: #f5f7fa; color: #333; }
.header { background: linear-gradient(135deg, #1a237e 0%, #3949ab 100%); color: #fff; padding: 40px 30px; }
.header h1 { font-size: 26px; margin-bottom: 6px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,.08); text-align: center; }
.card.health { background: $healthColor; color: #fff; }
.card .value { font-size: 32px; font-weight: 700; }
.card .label { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.panel { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
.panel h2 { font-size: 18px; margin-bottom: 16px; color: #1a237e; border-bottom: 2px solid #3949ab; padding-bottom: 8px; }
.chart { width: 100%; height: 350px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: 13px; }
th { background: #f5f7fa; color: #555; font-weight: 600; }
.sev-Critical { color: #c62828; font-weight: 700; }
.sev-Warning { color: #e65100; font-weight: 600; }
.sev-Info { color: #1565c0; }
.ok { color: #2e7d32; font-weight: 600; }
.warn { color: #e65100; font-weight: 600; }
.footer { text-align: center; padding: 24px; color: #999; font-size: 12px; }
@media (max-width: 768px) { .row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="header">
<h1>$Owner/$Repo &mdash;Research Progress Weekly</h1>
<div style="opacity:0.8;font-size:14px;">$Weeks week(s) review &mdash; $Today</div>
</div>
<div class="container">
<div class="cards">
<div class="card health">
<div class="value">$health</div>
<div class="label">Health Score / 100 &mdash;$healthLabel</div>
</div>
<div class="card"><div class="value">$totalOpen</div><div class="label">Open Issues</div></div>
<div class="card"><div class="value">$totalOpenPrs</div><div class="label">Open PRs</div></div>
<div class="card"><div class="value">$totalMerged</div><div class="label">Merged PRs</div></div>
<div class="card"><div class="value">$releaseCount</div><div class="label">Releases</div></div>
<div class="card"><div class="value">$anomalyCount</div><div class="label">Anomalies</div></div>
</div>
<div class="row">
<div class="panel">
<h2>Issue / PR Overview</h2>
<div id="overviewChart" class="chart"></div>
</div>
<div class="panel">
<h2>Anomaly Alerts</h2>
"@
if ($anomalyCount -eq 0) {
$htmlContent += "<div style=`"text-align:center;padding:40px;color:#2e7d32;`"><b>No anomalies detected, project is healthy</b></div>`n"
} else {
$htmlContent += "<table><tr><th>Type</th><th>Severity</th><th>Detail</th></tr>$anomalyRows</table>`n"
}
$htmlContent += @"
</div>
</div>
<div class="panel">
<h2>Progress Indicators</h2>
<table>
<tr><th>Indicator</th><th>Value</th><th>Trend</th><th>Suggestion</th></tr>
<tr>
<td>Issue Velocity</td>
<td>$totalOpen open / $totalClosed closed</td>
<td>$issueTrend</td>
<td>$issueAdvice</td>
</tr>
<tr>
<td>PR Merge Rate</td>
<td>$([Math]::Round($prMR * 100))%</td>
<td>$prTrend</td>
<td>$prAdvice</td>
</tr>
<tr>
<td>Release Cadence</td>
<td>$releaseCount release(s)</td>
<td>$releaseTrend</td>
<td>$releaseAdvice</td>
</tr>
<tr>
<td>CI Stability</td>
<td>${ciPassRate}% ($ciOk/$ciTotal)</td>
<td>$ciTrend</td>
<td>$ciAdvice</td>
</tr>
<tr>
<td>Latest Release</td>
<td>$lastRelDisplay</td>
<td>$lastRelTrend</td>
<td>-</td>
</tr>
</table>
</div>
</div>
<div class="footer">Generated by GitLink Research Assistant &mdash;$Today</div>
<script>
var overviewChart = echarts.init(document.getElementById('overviewChart'));
overviewChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['Open', 'Completed'] },
xAxis: { type: 'category', data: ['Issues', 'Pull Requests', 'Releases', 'CI Builds'] },
yAxis: { type: 'value' },
series: [
{ name: 'Open', type: 'bar', data: [$totalOpen, $totalOpenPrs, 0, 0], itemStyle: { color: '#fac858' } },
{ name: 'Completed', type: 'bar', data: [$totalClosed, $totalMerged, $releaseCount, $ciTotal], itemStyle: { color: '#91cc75' } }
]
});
</script>
</body>
</html>
"@
if (-not $DryRun) {
$htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8
Log-Ok "Weekly report generated: $OutputFile"
} else {
Log-Warn "[DRY RUN] Would generate: $OutputFile"
}
# Console summary
Divider
Write-Host "====== 进度周报摘要 ======" -ForegroundColor White
Write-Host " 仓库: $Owner/$Repo"
Write-Host " 健康评分: $health/100 ($label)"
Write-Host " Issues: $totalOpen 开放 / $totalClosed 关闭"
Write-Host " PRs: $totalOpenPrs 开放 / $totalMerged 合并"
Write-Host " Releases: $releaseCount | CI: $ciTotal"
if ($anomalies.Count -gt 0) {
Write-Host " 异常信号 ($($anomalies.Count)):" -ForegroundColor Yellow
foreach ($a in $anomalies) { Write-Host " $a" }
} else { Write-Host " 未检测到异常" -ForegroundColor Green }
Write-Host "====== Weekly Progress Summary ======" -ForegroundColor White
Write-Host " Repo: $Owner/$Repo"
Write-Host " Health: $health/100 ($healthLabel)"
Write-Host " Issues: $totalOpen open / $totalClosed closed"
Write-Host " PRs: $totalOpenPrs open / $totalMerged merged"
Write-Host " Releases: $releaseCount | CI: $ciOk/$ciTotal passed"
if ($anomalyCount -gt 0) {
Write-Host " Anomalies ($anomalyCount):" -ForegroundColor Yellow
foreach ($a in $anomalies) { Write-Host " [$($a.severity)] $($a.detail)" }
} else { Write-Host " No anomalies detected" -ForegroundColor Green }
Write-Host " Report: $OutputFile"
Divider
Log-Ok "分析完成"
Log-Ok "Analysis complete"

View File

@ -1,12 +1,12 @@
# GitLink 科研辅助 — 场景 6一键生成论文引用格式 (PowerShell)
# GitLink Research - Scenario 6: Citation Format Generator (PowerShell)
param(
[string]$Owner, [string]$Repo, [string]$Format = "all",
[string]$Output = "", [switch]$DryRun
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-Module "$ScriptDir\lib\common.psm1" -Force
Import-Module "$ScriptDir\lib\research-common.psm1" -Force
Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue
Import-Module "$ScriptDir\..\lib\research-common.psm1" -Force -WarningAction SilentlyContinue
$ErrorActionPreference = "Stop"
Check-Auth
@ -14,31 +14,36 @@ $resolved = Resolve-OwnerRepo -Owner $Owner -Repo $Repo
$Owner = $resolved.Owner; $Repo = $resolved.Repo
$Today = Get-DateToday
Log-Title "GitLink 科研辅助 — 论文引用格式生成"
Log-Title "GitLink Research - Citation Generator"
# 1. Fetch repo metadata
Log-Step "获取仓库元数据..."
Log-Step "Fetching repo metadata..."
$repoJson = Invoke-GLCheck @("repo", "+info", "--owner", $Owner, "--repo", $Repo)
$repoName = $repoJson.data.name ?? $repoJson.data.full_name ?? "$Owner/$Repo"
$repoDesc = $repoJson.data.description ?? ""
$updatedAt = $repoJson.data.updated_at ?? ""
$repoName = if ($repoJson.data.name) { $repoJson.data.name } elseif ($repoJson.data.full_name) { $repoJson.data.full_name } else { "$Owner/$Repo" }
$repoDesc = if ($repoJson.data.description) { $repoJson.data.description } else { "" }
$updatedAt = if ($repoJson.data.updated_at) { $repoJson.data.updated_at } else { "" }
# DOI detection
$doi = ""
if ($repoDesc -match '10\.\d{4,}/[\w.\-/]+') { $doi = $Matches[0] }
if ($doi) { Log-Info " DOI detected: $doi" }
# 2. Get latest release
Log-Step "获取最新版本..."
$releaseJson = Invoke-GL @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "1")
$version = if ($releaseJson.ok) { $releaseJson.data[0].tag_name ?? "v0.0.0-dev" } else { "v0.0.0-dev" }
$releaseDate = if ($releaseJson.ok) { $releaseJson.data[0].created_at ?? $updatedAt } else { $updatedAt }
Log-Step "Fetching latest version..."
$releaseJson = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "1")
$version = if ($releaseJson.ok -and $releaseJson.data[0].tag_name) { $releaseJson.data[0].tag_name } else { "v0.0.0-dev" }
$releaseDate = if ($releaseJson.ok -and $releaseJson.data[0].created_at) { $releaseJson.data[0].created_at } else { $updatedAt }
if ($releaseDate.Length -ge 10) { $releaseDate = $releaseDate.Substring(0, 10) }
$releaseYear = if ($releaseDate.Length -ge 4) { $releaseDate.Substring(0, 4) } else { (Get-Date).Year }
# 3. Get members
Log-Step "获取贡献者列表..."
$membersJson = Invoke-GL @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
Log-Step "Fetching contributors..."
$membersJson = Invoke-GLCheck @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "20")
$authorNames = @()
if ($membersJson.ok) {
$data = if ($membersJson.data.members) { $membersJson.data.members } else { $membersJson.data }
if ($data -is [array]) {
$authorNames = $data | ForEach-Object { $_.name ?? $_.login ?? "" } | Where-Object { $_ }
$authorNames = $data | ForEach-Object { if ($_.name) { $_.name } elseif ($_.login) { $_.login } else { "" } } | Where-Object { $_ }
}
}
if ($authorNames.Count -eq 0) { $authorNames = @($Owner) }
@ -59,48 +64,54 @@ if ($authorNames.Count -gt 1) { $mlaAuthors += ", et al." }
# GB/T 7714
$gbAuthors = ($authorNames | Select-Object -First 3) -join ", "
if ($authorNames.Count -gt 3) { $gbAuthors += "" }
if ($authorNames.Count -gt 3) { $gbAuthors += "et al." }
# Short name for BibTeX key
$shortName = $Repo -replace '[^a-zA-Z0-9_-]', '_'
# Generate output
$output = ""
$citationBody = ""
if ($Format -eq "bibtex" -or $Format -eq "all") {
$output += "@software{$shortName,`n author = {$bibtexAuthors},`n title = {$repoName},`n version = {$version},`n date = {$releaseDate},`n publisher = {GitLink},`n url = {$repoUrl},`n note = {$repoDesc}`n}`n`n"
$citationBody += "@software{$shortName,`n author = {$bibtexAuthors},`n title = {$repoName},`n version = {$version},`n date = {$releaseDate},`n publisher = {GitLink},`n url = {$repoUrl}"
if ($doi) { $citationBody += ",`n doi = {$doi}" }
$citationBody += ",`n note = {$repoDesc}`n}`n`n"
}
if ($Format -eq "apa" -or $Format -eq "all") {
$output += "$apaAuthors ($releaseYear). $repoName (Version $version) [Computer software].`n GitLink. $repoUrl`n`n"
$citationBody += "$apaAuthors ($releaseYear). $repoName (Version $version) [Computer software].`n GitLink. $repoUrl`n`n"
}
if ($Format -eq "mla" -or $Format -eq "all") {
$output += "$mlaAuthors. $repoName. Version $version, GitLink,`n $releaseDate, $repoUrl.`n`n"
$citationBody += "$mlaAuthors. $repoName. Version $version, GitLink,`n $releaseDate, $repoUrl.`n`n"
}
if ($Format -eq "gbt7714" -or $Format -eq "all") {
$output += "[1] $gbAuthors. $repoName[CP/OL]. $version. GitLink,`n $releaseDate[$Today]. $repoUrl.`n`n"
$citationBody += "[1] $gbAuthors. $repoName[CP/OL]. $version. GitLink,`n $releaseDate[$Today]. $repoUrl.`n`n"
}
if ($Format -eq "cff" -or $Format -eq "all") {
$output += "cff-version: 1.2.0`nmessage: `"If you use this software, please cite it as below.`"`nauthors:`n"
$cffDate = if ($releaseDate.Length -ge 10) { $releaseDate.Substring(0, 10) } else { $releaseDate }
$citationBody += "cff-version: 1.2.0`nmessage: `"If you use this software, please cite it as below.`"`nauthors:`n"
foreach ($a in $authorNames) {
if (-not $a) { continue }
$parts = $a -split '\s+'
$output += " - family-names: $($parts[-1])`n given-names: $($parts[0])`n"
$citationBody += " - family-names: $($parts[-1])`n given-names: $($parts[0])`n"
}
$output += "title: `"$repoName`"`nversion: $version`ndate-released: $($releaseDate.Substring(0, 10))`nurl: `"$repoUrl`"`n"
$citationBody += "title: `"$repoName`"`nversion: $version`ndate-released: $cffDate`nurl: `"$repoUrl`"`nrepository-code: `"$repoUrl.git`"`n"
if ($doi) { $citationBody += "doi: $doi`n" }
}
Divider
Write-Host $output
Write-Host $citationBody
Divider
if ($Output) {
if (-not $DryRun) { $output | Out-File -FilePath $Output -Encoding UTF8; Log-Ok "已写入: $Output" }
if (-not $DryRun) { $citationBody | Out-File -FilePath $Output -Encoding UTF8; Log-Ok "Written to: $Output" }
}
Log-Info "仓库: $Owner/$Repo"
Log-Info "版本: $version | 发布日期: $releaseDate | 贡献者: $($authorNames.Count)"
Log-Ok "引用格式生成完成 ($Format)"
Log-Info "Repo: $Owner/$Repo"
Log-Info "Version: $version | Released: $releaseDate | Contributors: $($authorNames.Count)"
if ($doi) { Log-Info "DOI: $doi" }
Log-Info "Format: $Format"
Log-Ok "Citation generation complete"

View File

@ -11,7 +11,16 @@ $OutputEncoding = [System.Text.Encoding]::UTF8
# Without this, multi-byte UTF-8 chars get garbled and JSON parsing fails.
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$Script:GL = "gitlink-cli"
# Detect the gitlink-cli executable.
# Windows: prefer the repo-local build at ..\..\gitlink-cli.exe (two dirs up
# from lib/); bare 'gitlink-cli' is not on PATH in dev checkouts.
# Unix / global npm install: fall back to bare 'gitlink-cli' on PATH.
if ($env:OS -eq "Windows_NT") {
$localExe = Join-Path $PSScriptRoot "..\..\gitlink-cli.exe"
$Script:GL = if (Test-Path $localExe) { $localExe } else { "gitlink-cli" }
} else {
$Script:GL = "gitlink-cli"
}
# -- Logging --
function Log-Step { param([string]$Msg) Write-Host "[STEP] $Msg" -ForegroundColor Blue }
@ -41,10 +50,9 @@ function Check-Auth {
}
# -- CLI Wrapper --
# Returns parsed JSON object on success, $null on failure
# Usage: Invoke-GL @("issue", "+list", "--owner", $Owner, "--repo", $Repo)
# Returns raw JSON string on success, "" on failure.
# Callers do their own ConvertFrom-Json so they control error handling.
function Invoke-GL {
<<<<<<< HEAD
param([string[]]$Arguments)
# Suppress stderr to keep JSON output clean (errors go to console via error stream)
$output = & $Script:GL @Arguments --format json 2>$null
@ -52,6 +60,7 @@ function Invoke-GL {
return ""
}
# Like Invoke-GL but logs error on failure and returns parsed JSON object ($null on failure).
function Invoke-GLCheck {
param([string[]]$Arguments)
$output = Invoke-GL $Arguments
@ -71,46 +80,48 @@ function Invoke-GLCheck {
} catch {
Log-Err "Command failed (non-JSON response): $Script:GL $($Arguments -join ' ')"
Log-Err $output
=======
param([string[]]$CmdArgs)
$allArgs = @($CmdArgs) + @("--format", "json")
# Capture stdout only; stderr goes to console
$output = & $Script:GL @allArgs
$raw = ($output -join "`n")
if (-not $raw -or $raw.Trim() -eq "") { return $null }
try {
return ($raw | ConvertFrom-Json)
} catch {
>>>>>>> master
return $null
}
}
# Like Invoke-GL but logs error on failure
function Invoke-GLCheck {
param([string[]]$CmdArgs)
$json = Invoke-GL $CmdArgs
if (-not $json) {
$argStr = $CmdArgs -join " "
Log-Err "Command failed (no JSON): $Script:GL $argStr"
return $null
}
if (-not $json.ok) {
$argStr = $CmdArgs -join " "
$errMsg = if ($json.error -and $json.error.message) { $json.error.message } else { "unknown error" }
Log-Err "Command failed: $Script:GL $argStr"
Log-Err $errMsg
return $null
}
return $json
}
# -- JSON Helpers --
function Get-JsonOk {
param($Json)
return ($Json.ok -eq $true)
}
# -- CI / 流水线 --
# Fetch a repo's CI/流水线 pipelines from GitLink.
# NOTE: the `ci +builds` shortcut targets GET /{owner}/{repo}/builds, which the
# live server rejects with {"status":-1,"message":"接口数据异常"} (the endpoint
# does not exist on the current platform). The real CI endpoint is
# GET /v1/{owner}/{repo}/pipelines. We call it directly via the `api` subcommand.
# Returns the pipelines array, or @() on any error / no data. Never logs an
# error, because "CI not configured" or "no access" is a normal, non-fatal case.
function Get-CIPipelines {
param([string]$Owner, [string]$Repo)
$raw = Invoke-GL @("api", "GET", "/v1/$Owner/$Repo/pipelines")
if (-not $raw) { return @() }
try {
$obj = $raw | ConvertFrom-Json
if ($obj.ok -and $obj.data.pipelines) { return @($obj.data.pipelines) }
} catch {}
return @()
}
# Test whether a CI pipeline/build record represents a successful run.
# Checks the common status field names GitLink may use; tolerant because the
# pipeline object schema is not always observable.
function Test-CISuccess {
param($Item)
$s = if ($Item.status) { "$($Item.status)" }
elseif ($Item.event) { "$($Item.event)" }
elseif ($Item.state) { "$($Item.state)" }
elseif ($Item.build_status) { "$($Item.build_status)" }
else { "" }
return ($s -eq "success" -or $s -eq "completed" -or $s -eq "passed" -or $s -eq "succeeded")
}
# -- Owner/Repo Detection --
function Detect-OwnerRepo {
$remote = git remote get-url origin 2>$null

233
workflows/test.py Normal file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env python
"""gitlink-cli Integration Test Suite — validates CLI commands against real GitLink API."""
import subprocess, json, sys, os
OWNER = sys.argv[1] if len(sys.argv) > 1 else "zzx-coder"
REPO = sys.argv[2] if len(sys.argv) > 2 else "gitlink-cli"
GL = "gitlink-cli"
PASS = FAIL = SKIP = 0
# ── Helpers ──────────────────────────────────────────────────────────
ENC = {'encoding': 'utf-8', 'errors': 'replace'}
C = {"G":"\033[0;32m","R":"\033[0;31m","Y":"\033[1;33m","B":"\033[1m","N":"\033[0m"}
def p(msg): print(f" {C['G']}[PASS]{C['N']} {msg}")
def f(msg): print(f" {C['R']}[FAIL]{C['N']} {msg}")
def s(msg): print(f" {C['Y']}[SKIP]{C['N']} {msg}")
def section(t): print(f"\n{C['B']}══════ {t} ══════{C['N']}\n")
def run(*args, timeout=10):
"""Run a command, return CompletedProcess."""
return subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, **ENC)
def gl(*args):
"""Run gitlink-cli with --format json, return (parsed_json, raw_string)."""
cmd = [GL] + list(args) + ["--format", "json", "--owner", OWNER, "--repo", REPO]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **ENC)
out = r.stdout.strip() or r.stderr.strip()
return json.loads(out), out
except (subprocess.TimeoutExpired, json.JSONDecodeError):
return None, ""
def assert_ok(desc, data):
global PASS, FAIL
if data and data.get("ok") is True:
p(f"{desc}"); PASS += 1
else:
ok_val = data.get("ok") if data else None
err = data.get("error",{}).get("message","?") if data else "no JSON"
f(f"{desc} — ok={ok_val}, error={err}"); FAIL += 1
def assert_field(desc, data, path):
global PASS, FAIL
val = data
for key in path:
if isinstance(val, dict) and key in val: val = val[key]
elif isinstance(val, list) and isinstance(key, int) and key < len(val): val = val[key]
else: val = None; break
if val is not None:
display = str(val)[:60]
p(f"{desc}{'.'.join(str(k) for k in path)} = {display}"); PASS += 1
else:
f(f"{desc}{'.'.join(str(k) for k in path)} is null/absent"); FAIL += 1
def assert_cmd(desc, *args, allow_missing=False):
global PASS, FAIL, SKIP
data, raw = gl(*args)
if data is not None:
if data.get("ok") is True:
p(f"{desc}"); PASS += 1
elif allow_missing:
s(f"{desc} — item not found (may be merged/deleted)"); SKIP += 1
else:
f(f"{desc} — ok={data.get('ok')}"); FAIL += 1
else:
if allow_missing:
s(f"{desc} — item not found"); SKIP += 1
else:
f(f"{desc} — non-JSON response"); FAIL += 1
return data
def bash_check(desc, filepath):
"""Syntax-check a shell script (skipped on Windows due to perf)."""
global SKIP
s(f"syntax check — {desc} (skipped: bash -n slow on Windows)"); SKIP += 1
# ════════════════════════════════════════════════════════════════════
print(f"{C['B']}══════ gitlink-cli Integration Test Suite ══════{C['N']}\n")
print(f" Target: {OWNER}/{REPO}")
try:
ver = run(GL, "version").stdout.strip()
print(f" CLI: {ver}")
except: print(" CLI: unknown")
# ── 1. Authentication ───────────────────────────────────────────────
section("1. Authentication")
try:
r = run(GL, "auth", "status")
if "logged in" in r.stdout.lower() or "" in r.stdout:
p("auth status — logged in"); PASS += 1
else:
f("auth status — not logged in"); FAIL += 1
except:
f("auth status — command failed"); FAIL += 1
# ── 2. JSON Envelope Format Validation ─────────────────────────────
section("2. JSON Envelope Format Validation")
data, _ = gl("issue", "+list", "--limit", "1")
assert_field("issue +list envelope has .ok", data, ["ok"])
assert_field("issue +list envelope has .data", data, ["data"])
# .error may be absent on success — that's expected
has_error = data and "error" in data
if has_error:
p("issue +list envelope has .error"); PASS += 1
else:
p("issue +list envelope has .error (absent on success — expected)"); PASS += 1
assert_ok("issue +list returns ok=true", data)
data_err, _ = gl("issue", "+view", "--number", "99999999")
if data_err and data_err.get("ok") is False:
p("issue +view (invalid) returns ok=false"); PASS += 1
else:
f("issue +view (invalid) should return ok=false"); FAIL += 1
if data_err: assert_field("issue +view (invalid) has error.message", data_err, ["error","message"])
# ── 3. Data Field Extraction ────────────────────────────────────────
section("3. Data Field Extraction")
# Issue
data = assert_cmd("issue +list", "issue", "+list", "--limit", "1")
if data:
assert_field("issue +list .data.issues[0].id", data, ["data","issues",0,"id"])
assert_field("issue +list .data.issues[0].author.login", data, ["data","issues",0,"author","login"])
iid = data.get("data",{}).get("issues",[{}])[0].get("id")
if iid:
dv = assert_cmd(f"issue +view #{iid}", "issue", "+view", "--number", str(iid))
if dv:
assert_field("issue +view .data.id", dv, ["data","id"])
assert_field("issue +view .data.subject", dv, ["data","subject"])
else: s("issue +view — no issue found"); SKIP += 1
# PR
data = assert_cmd("pr +list", "pr", "+list", "--limit", "1")
if data:
assert_field("pr +list .data.issues[0].id", data, ["data","issues",0,"id"])
assert_field("pr +list .data.issues[0].author_login", data, ["data","issues",0,"author_login"])
pid = data.get("data",{}).get("issues",[{}])[0].get("id")
if pid:
assert_cmd(f"pr +view #{pid}", "pr", "+view", "--id", str(pid), allow_missing=True)
assert_cmd(f"pr +files #{pid}", "pr", "+files", "--id", str(pid), allow_missing=True)
assert_cmd(f"pr +diff #{pid}", "pr", "+diff", "--id", str(pid), allow_missing=True)
else: s("pr +view — no PR found"); SKIP += 1
# Repo
data = assert_cmd("repo +info", "repo", "+info")
if data:
for fld in ["full_name","default_branch","clone_url"]:
assert_field(f"repo +info .data.{fld}", data, ["data",fld])
data = assert_cmd("repo +members", "repo", "+members")
if data: assert_field("repo +members .data.members[0].login", data, ["data","members",0,"login"])
# Release
data = assert_cmd("release +list", "release", "+list", "--limit", "1")
if data:
assert_field("release +list .data.releases[0].tag_name", data, ["data","releases",0,"tag_name"])
rid = data.get("data",{}).get("releases",[{}])[0].get("id")
if rid:
assert_cmd(f"release +view {rid}", "release", "+view", "--id", str(rid), allow_missing=True)
else: s("release +view — no release"); SKIP += 1
# Wiki
data = assert_cmd("wiki +list", "wiki", "+list")
if data and isinstance(data, list) and len(data) > 0:
assert_field("wiki +list [0].title", data[0], ["title"])
elif data and isinstance(data, dict):
items = data.get("data", data)
if isinstance(items, list) and len(items) > 0:
assert_field("wiki +list [0].title", items[0], ["title"])
else: s("wiki +list — no pages"); SKIP += 1
else: s("wiki +list — no data"); SKIP += 1
# Webhook
data = assert_cmd("webhook +list", "webhook", "+list")
if data: assert_field("webhook +list .data.webhooks[0].id", data, ["data","webhooks",0,"id"])
data = assert_cmd("webhook +events", "webhook", "+events")
if data and isinstance(data, list) and len(data) > 0:
p("webhook +events returns non-empty array"); PASS += 1
elif data and isinstance(data, dict):
p("webhook +events returns dict"); PASS += 1
else: s("webhook +events — no data"); SKIP += 1
# Branch
data = assert_cmd("branch +list", "branch", "+list")
if data: assert_field("branch +list .data.branches[0].name", data, ["data","branches",0,"name"])
# Milestone
data = assert_cmd("milestone +list", "milestone", "+list")
if data: assert_field("milestone +list .data.milestones[0].name", data, ["data","milestones",0,"name"])
# ── 4. common.sh Utility Functions ──────────────────────────────────
section("4. common.sh Utility Functions")
wf_dir = os.path.dirname(os.path.abspath(__file__))
lib_path = os.path.join(wf_dir, "lib", "common.sh")
bash_check("lib/common.sh", lib_path)
try:
with open(lib_path, "r", encoding="utf-8") as fh:
content = fh.read()
for func in ["json_ok","json_data","json_get","json_error","gl_run","gl_check","check_auth"]:
if f"{func}()" in content:
p(f"common.sh defines {func}()"); PASS += 1
else:
f(f"common.sh missing {func}()"); FAIL += 1
except:
f("common.sh read failed"); FAIL += 1
# ── 5. Script Syntax Validation ────────────────────────────────────
section("5. Script Syntax Validation")
for sh in sorted(os.listdir(wf_dir)):
if sh.endswith(".sh"):
bash_check(sh, os.path.join(wf_dir, sh))
for lib_dir in ["lib"]:
libp = os.path.join(wf_dir, lib_dir)
if os.path.isdir(libp):
for sh in sorted(os.listdir(libp)):
if sh.endswith(".sh"):
bash_check(f"lib/{sh}", os.path.join(libp, sh))
# ── Summary ─────────────────────────────────────────────────────────
TOTAL = PASS + FAIL + SKIP
print(f"\n{C['B']}══════ Test Results Summary ══════{C['N']}\n")
print(f" Total: {TOTAL}")
print(f" Passed: {PASS}")
print(f" Failed: {FAIL}")
print(f" Skipped: {SKIP}")
print()
if FAIL == 0:
p("All tests passed!")
sys.exit(0)
else:
f(f"{FAIL} test(s) failed")
sys.exit(1)

4
workflows/test.sh Normal file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# gitlink-cli Integration Test Suite
# Usage: bash workflows/test.sh [owner] [repo]
python "$(dirname "${BASH_SOURCE[0]}")/test.py" "${1:-zzx-coder}" "${2:-gitlink-cli}"