forked from Gitlink/gitlink-cli
Merge origin/master into zk_branch, resolve conflicts in milestone/issue/README
This commit is contained in:
commit
d9ff6d134d
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -15,4 +15,4 @@ clean:
|
|||
rm -f $(BINARY)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
go test ./... -race
|
||||
|
|
|
|||
923
README.zh-CN.md
923
README.zh-CN.md
File diff suppressed because it is too large
Load Diff
521
demo/index.html
521
demo/index.html
|
|
@ -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 模式 C:Wiki 增量更新",
|
||||
engine: "claude",
|
||||
prompt: "请把 Issue #11 补充到已有的 Issue 知识库 Wiki 页面中。\n测试仓库:zzx-coder/test-repo"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "m3-smoke",
|
||||
icon: "🧪",
|
||||
name: "端到端冒烟测试 (15 步)",
|
||||
commands: [
|
||||
{
|
||||
label: "运行冒烟测试(连通性验证)",
|
||||
engine: "powershell",
|
||||
cmd: "# 端到端冒烟测试(来自测试 5.1,15 步连通性验证)\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 = '🤖>';
|
||||
var meta = AGENT_META[currentAgent];
|
||||
inputPrompt.innerHTML = meta.icon + '>';
|
||||
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>';
|
||||
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 + '>';
|
||||
} else {
|
||||
inputPrompt.innerHTML = 'PS>';
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// 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 ? '🤖>' : 'PS>';
|
||||
var meta = AGENT_META[currentAgent];
|
||||
var promptHtml = isClaude ? meta.icon + '>' : 'PS>';
|
||||
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 = '';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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('示例三:批量关闭 Issue(dry-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',
|
||||
'获取已合并 PR:gitlink-cli pr +list --state merged --format json',
|
||||
'获取 commit 历史:git log --oneline v2.0.0..HEAD',
|
||||
'AI 分类整理:新功能、Bug 修复、性能优化、Breaking Changes',
|
||||
'生成 Release Notes:gitlink-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',
|
||||
'创建 Issues:gitlink-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',
|
||||
'查询跨仓库 PR:gitlink-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()
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python -c \"import docx; print\\(''ok''\\)\")",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python generate_report.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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 辅助科研(可选完成,视完成情况额外加分)
|
||||
定位:科研场景智能化赋能 | 难度:中 | 要求:无需 Go,Markdown + CLI 调用即可
|
||||
任务:依托 gitlink-cli 数据获取、命令调用与 AI Agent 能力,融合数据分析、知识图谱、AI 挖掘技术,面向
|
||||
科研工作者、课题组与科研团队,将 GitLink 平台代码托管、协作数据转化为科研创新支撑能力,实现科研项目
|
||||
分析、主体画像、热点追踪、创新启发、合规校验等全链路科研辅助服务,打通开源代码生态与学术科研的融
|
||||
合通道,包括但不限于以下场景:
|
||||
• 仓库级科研项目洞悉
|
||||
• 科研热点追踪与知识图谱构建
|
||||
• 科研项目合规与复现性检查
|
||||
• 科研协作智能匹配
|
||||
• 科研进度智能跟踪与预警
|
||||
|
||||
|
|
@ -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.
|
|
@ -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 客户端、认证、配置、输出格式化、错误处理。
|
||||
(5)AI 扩展层: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。
|
||||
|
||||
(4)Shell 补全:支持 bash、zsh、fish、powershell 四种 shell 的自动补全。
|
||||
|
||||
(5)帮助系统:每个命令支持 --help,显示用法、参数说明和示例。
|
||||
|
||||
用户操作流程:
|
||||
用户打开终端 → 输入 gitlink-cli auth login 登录 → 使用具体命令操作资源
|
||||
→ 输出结果以 table/json/yaml 格式显示 → 可通过管道传递给其他工具
|
||||
|
||||
|
||||
2.3 数据库设计
|
||||
|
||||
本项目不使用传统数据库。数据存储分为两部分:
|
||||
|
||||
(1)配置文件存储:
|
||||
路径:~/.config/gitlink-cli/config.yaml
|
||||
内容:base_url(API 地址)、default_format(输出格式)、editor、pager
|
||||
格式:YAML
|
||||
|
||||
(2)凭证存储:
|
||||
使用 go-keyring 库,调用操作系统原生密钥管理:
|
||||
- macOS:Keychain
|
||||
- Linux:Secret Service(GNOME Keyring / KDE Wallet)
|
||||
- Windows:Credential Manager
|
||||
Fallback:~/.config/gitlink-cli/credentials(文件权限 0600)
|
||||
|
||||
存储的数据结构:
|
||||
Token(字符串)→ 关联 gitlink.org.cn 的 Bearer Token
|
||||
Config(YAML)→ 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 的骨架。
|
||||
|
||||
核心类结构:
|
||||
|
||||
(1)cobra.Command(来自 spf13/cobra 库)
|
||||
- 每个命令对应一个 cobra.Command 实例
|
||||
- rootCmd 是根命令,所有子命令通过 AddCommand 挂载
|
||||
- root.go 定义全局 PersistentFlags(--owner、--repo、--format 等)
|
||||
|
||||
(2)common.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
|
||||
}
|
||||
|
||||
(3)common.Flag 结构体:
|
||||
type Flag struct {
|
||||
Name string
|
||||
Short string
|
||||
Usage string
|
||||
Required bool
|
||||
Default string
|
||||
Bool bool
|
||||
Choices []string
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
(4)common.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 并输出
|
||||
|
||||
(5)register.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 行
|
||||
- ci:CI/CD 操作(builds/logs/restart/stop)— 117 行
|
||||
- file:文件操作(ls/read/create/update/delete/commits/diff)— 886 行
|
||||
- issue:Issue 操作(14 个命令,含 6 种批量操作)— 3,058 行
|
||||
- milestone:里程碑操作(list/create/view/update/delete/status)— 525 行
|
||||
- org:组织操作(list/info/members/create/update)— 476 行
|
||||
- pr:PR 操作(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 行
|
||||
- webhook:Webhook 操作(list/create/view/update/delete/test/events)— 649 行
|
||||
- wiki:Wiki 操作(list/create/view/update/delete/lint/fix/sync)— 1,612 行
|
||||
|
||||
最大的两个模块是 issue(3,058 行)和 wiki(1,612 行)。issue 模块包含批量操作功能,wiki 模块处理双域名架构和 base64 编解码。
|
||||
|
||||
批量操作设计模式(以 issue 为例):
|
||||
- BatchResult:单条操作结果(number/action/status/error)
|
||||
- BatchSummary:汇总信息(total/succeeded/failed/results)
|
||||
- 输入灵活:--numbers(内联)和 --from(CSV 文件)可同时使用
|
||||
- dry-run 统一:所有批量命令支持 --dry-run 预览
|
||||
- 错误不中断:单条失败不影响后续处理
|
||||
|
||||
|
||||
3.3 核心库层设计
|
||||
|
||||
核心库层位于 internal/ 目录,包含 7 个子包:
|
||||
|
||||
(1)internal/auth — 认证模块(3 个文件,272 行)
|
||||
- login.go:登录流程(用户名密码或 Token 粘贴)
|
||||
- token_store.go:Token 存储(go-keyring 跨平台支持)
|
||||
- transport.go:HTTP Transport,自动在请求 Header 中注入 Bearer Token
|
||||
|
||||
(2)internal/client — HTTP 客户端(2 个文件,319 行)
|
||||
- client.go:封装 Get/Post/Put/Delete 方法,自动追加 .json 后缀,双重错误检查(HTTP 状态码 + JSON body 中的 status 字段)
|
||||
- pagination.go:分页迭代器,自动遍历 Kaminari 风格分页
|
||||
|
||||
(3)internal/compliance — 合规检查(4 个文件,685 行)
|
||||
- cmd.go:compliance 子命令定义
|
||||
- scanner.go:扫描器实现
|
||||
- license.go:许可证检测
|
||||
- rules.go:合规规则定义
|
||||
|
||||
(4)internal/config — 配置管理(1 个文件,125 行)
|
||||
- config.go:读写 ~/.config/gitlink-cli/config.yaml
|
||||
|
||||
(5)internal/context — 上下文解析(1 个文件,91 行)
|
||||
- repo.go:从 git remote 的 origin URL 自动解析 owner 和 repo
|
||||
|
||||
(6)internal/errors — 错误处理(1 个文件,150 行)
|
||||
- errors.go:统一错误类型 CLIError,包含 Kind(错误分类)、Message、Suggestion、Command 字段
|
||||
- 错误分类:KindAuth、KindInput、KindForbidden、KindNotFound、KindServer、KindUnknown
|
||||
|
||||
(7)internal/output — 输出格式化(3 个文件,425 行)
|
||||
- envelope.go:定义 Envelope 结构体 {OK, Data, Error, Meta}
|
||||
- formatter.go:三种输出格式(JSON/YAML/Table),table 使用 tabwriter 对齐,支持 ANSI 颜色、列过滤、值截断控制
|
||||
|
||||
|
||||
4、系统动态模型
|
||||
|
||||
4.1 用例一:用户认证与仓库操作
|
||||
|
||||
用例描述:用户首次使用 gitlink-cli,完成登录并查看仓库信息。
|
||||
|
||||
参与者:开发者(终端用户)
|
||||
|
||||
前置条件:已安装 gitlink-cli,网络可访问 gitlink.org.cn
|
||||
|
||||
主要流程:
|
||||
|
||||
(1)用户执行 gitlink-cli auth login
|
||||
(2)系统提示输入用户名和密码
|
||||
(3)系统调用 POST /api/accounts/login 获取 Token
|
||||
(4)Token 存入 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)系统输出 BatchSummary:total=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)系统输出 BatchSummary:total=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 中说"帮我跑社区运营工作流"
|
||||
(2)Claude Code 触发 gitlink-workflows skill,显示功能菜单
|
||||
(3)用户选择"社区运营自动化"
|
||||
(4)Claude Code 调用 gitlink-cli issue +list --state open --format json
|
||||
(5)Claude Code 分析 Issue 列表,按类型分类(bug/feature/question)
|
||||
(6)Claude Code 调用 gitlink-cli issue +label-add 添加分类标签
|
||||
(7)Claude Code 调用 gitlink-cli pr +list 获取近期 PR
|
||||
(8)Claude Code 生成周报摘要,包含 Issue 统计、PR 合并情况、贡献者活跃度
|
||||
|
||||
Skill 调用链:
|
||||
gitlink-shared(认证检查)→ gitlink-issue(Issue 操作)→ gitlink-pr(PR 操作)→ 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.sh(Linux/macOS)、install.ps1(Windows)
|
||||
|
||||
(3)目标平台:
|
||||
- Windows(amd64)
|
||||
- macOS(amd64、arm64)
|
||||
- Linux(amd64、arm64)
|
||||
|
||||
(4)依赖服务:
|
||||
- GitLink API:https://www.gitlink.org.cn/api(主站 API)
|
||||
- GitLink Gateway:https://gateway.gitlink.org.cn/api(Wiki API 专用)
|
||||
- OS Keychain:系统密钥管理服务(存储 Token)
|
||||
|
||||
(5)CI/CD 集成:
|
||||
- GitHub Actions:.github/workflows/release.yml 自动构建和发布
|
||||
- GitLink DevOps:.devops/gitlink-cli-autodeploy.yml 平台自动部署
|
||||
|
||||
(6)部署架构:
|
||||
|
||||
用户终端
|
||||
↓
|
||||
gitlink-cli(本地二进制)
|
||||
↓ HTTP/HTTPS
|
||||
GitLink API Server(gitlink.org.cn)
|
||||
↓
|
||||
GitLink Gateway(gateway.gitlink.org.cn,Wiki 专用)
|
||||
|
||||
gitlink-cli 是纯客户端工具,不运行后台服务。所有数据存储在用户本地(配置文件 + OS Keychain),业务数据完全来自 GitLink 平台 API 的实时查询。
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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列表
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. 贡献者活跃度
|
||||
|
|
|
|||
|
|
@ -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 和 PR(Shortcut 命令),再收集 contributors(Raw 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 时,"无近期发布"项自动跳过
|
||||
|
|
|
|||
|
|
@ -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`)更可靠
|
||||
|
|
|
|||
|
|
@ -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:贡献者活跃度
|
||||
|
||||
|
|
|
|||
BIN
webhook-test.txt
BIN
webhook-test.txt
Binary file not shown.
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} "
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@
|
|||
#Requires -Version 5.1
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true, HelpMessage="Webhook 回调 URL,GitLink 会向此 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 '&', '&'
|
||||
$s = $s -replace '<', '<'
|
||||
$s = $s -replace '>', '>'
|
||||
$s = $s -replace '"', '"'
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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//&/&}"
|
||||
s="${s//</<}"
|
||||
s="${s//>/>}"
|
||||
s="${s//\"/"}"
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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**: 项目从创建之初就有版本管理
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ metadata:
|
|||
→ PR Review、AI 四维度评分、自动合并
|
||||
|
||||
3. 项目一键初始化
|
||||
→ 创建仓库、README、CI 配置、初始 Issues、分支保护
|
||||
→ 创建仓库、README/LICENSE/CI 文件、里程碑、初始 Issues、分支保护
|
||||
|
||||
4. 多仓库协同
|
||||
→ 跨仓库 Issue/PR 追踪、状态 Dashboard、协同发版
|
||||
|
|
|
|||
|
|
@ -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 — 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 — $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 — $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"
|
||||
|
|
|
|||
|
|
@ -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 "✅" }
|
||||
elseif ($s -eq 0.5) { return "⚠️" }
|
||||
else { return "❌" }
|
||||
}
|
||||
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 —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 —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 & 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 —$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"
|
||||
|
|
|
|||
|
|
@ -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 —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 —Research Progress Weekly</h1>
|
||||
<div style="opacity:0.8;font-size:14px;">$Weeks week(s) review — $Today</div>
|
||||
</div>
|
||||
<div class="container">
|
||||
|
||||
<div class="cards">
|
||||
<div class="card health">
|
||||
<div class="value">$health</div>
|
||||
<div class="label">Health Score / 100 —$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 —$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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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}"
|
||||
Loading…
Reference in New Issue