forked from Gitlink/gitlink-cli
394 lines
17 KiB
Bash
394 lines
17 KiB
Bash
#!/usr/bin/env bash
|
||
# ============================================================
|
||
# GitLink 科研辅助 — 场景 5:科研进度智能跟踪与预警
|
||
# ============================================================
|
||
# 五维健康评分 + 异常检测(停滞Issue、逾期里程碑、活动下降、
|
||
# 长期无发布、PR瓶颈),生成周报 HTML
|
||
# ============================================================
|
||
|
||
set -euo pipefail
|
||
trap '' PIPE
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
source "$SCRIPT_DIR/../lib/common.sh"
|
||
|
||
# ── Configuration ────────────────────────────────────────────────────
|
||
OUTPUT_DIR="${SCRIPT_DIR}/../../output"
|
||
OUTPUT_FILE=""
|
||
PUBLISH_WIKI="true"
|
||
LOOKBACK_WEEKS=4
|
||
ORG_MODE=""
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
Usage: 10-research-progress.sh [options]
|
||
|
||
Options:
|
||
--owner <owner> Repository owner (single repo mode)
|
||
--repo <repo> Repository name (single repo mode)
|
||
--org <org> Organization name (multi-repo mode)
|
||
--weeks <N> Lookback weeks (default: 4)
|
||
--output <file> Output HTML file path
|
||
--no-wiki Skip publishing to Wiki
|
||
--dry-run Preview mode
|
||
|
||
Examples:
|
||
10-research-progress.sh --owner zzx-coder --repo gitlink-cli
|
||
10-research-progress.sh --org zzx-coder --weeks 4
|
||
EOF
|
||
exit 0
|
||
}
|
||
|
||
parse_common_args "$@"
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--output) OUTPUT_FILE="$2"; shift 2 ;;
|
||
--no-wiki) PUBLISH_WIKI="false"; shift ;;
|
||
--org) ORG_MODE="$2"; shift 2 ;;
|
||
--weeks) LOOKBACK_WEEKS="$2"; shift 2 ;;
|
||
*) shift ;;
|
||
esac
|
||
done
|
||
|
||
# ── Health Score Calculation ─────────────────────────────────────────
|
||
calc_health() {
|
||
local total_open="$1" total_closed="$2" total_merged="$3" total_open_prs="$4"
|
||
local release_count="$5" ci_ok="$6" ci_total="$7"
|
||
local total_open_prev="$8" total_closed_prev="$9"
|
||
|
||
# Issue velocity: closed per day (normalized to 0-1, target 1/day)
|
||
local iv
|
||
iv=$(awk -v c="$total_closed" -v w="$LOOKBACK_WEEKS" 'BEGIN { v=c/(w*7); printf "%.4f", (v>1?1:v) }')
|
||
|
||
# PR merge rate
|
||
local pr_mr
|
||
pr_mr=$(awk -v m="$total_merged" -v o="$total_open_prs" 'BEGIN { t=m+o; printf "%.4f", (t>0?m/t:0) }')
|
||
|
||
# Release cadence
|
||
local rc
|
||
rc=$(awk -v r="$release_count" 'BEGIN { printf "%.4f", (r>=3?1:(r>=1?0.5:0.2)) }')
|
||
|
||
# CI pass rate
|
||
local ci_score
|
||
ci_score=$(awk -v ok="$ci_ok" -v t="$ci_total" 'BEGIN { printf "%.4f", (t>0?ok/t:0) }')
|
||
|
||
# Activity trend
|
||
local trend diff
|
||
diff=$(awk -v cur="$total_closed" -v prev="$total_closed_prev" 'BEGIN { printf "%.4f", (prev>0)?(cur-prev)/prev:0 }')
|
||
trend=$(awk -v d="$diff" 'BEGIN { t=d+0.5; printf "%.4f", (t<0?0:(t>1?1:t)) }')
|
||
|
||
# Weighted sum
|
||
local health
|
||
health=$(awk -v iv="$iv" -v pr="$pr_mr" -v rc="$rc" -v ci="$ci_score" -v tr="$trend" \
|
||
'BEGIN { printf "%.1f", (iv*0.30+pr*0.25+rc*0.25+ci*0.10+tr*0.10)*100 }')
|
||
echo "$health"
|
||
}
|
||
|
||
# ── Anomaly Detection ────────────────────────────────────────────────
|
||
detect_anomalies() {
|
||
local anomalies=""
|
||
local anomaly_count=0
|
||
|
||
# Stalled issues: open > 60 days (simulated via count threshold)
|
||
if [[ ${1:-0} -gt 20 ]]; then
|
||
anomalies="${anomalies}{\"type\":\"stalled_issue\",\"severity\":\"Warning\",\"detail\":\"开放 Issue 数量(${1})偏高,可能存在停滞\"},"
|
||
anomaly_count=$((anomaly_count + 1))
|
||
fi
|
||
|
||
# PR bottleneck
|
||
if [[ ${3:-0} -gt 5 ]]; then
|
||
anomalies="${anomalies}{\"type\":\"pr_bottleneck\",\"severity\":\"Warning\",\"detail\":\"${3} 个开放 PR 积压\"},"
|
||
anomaly_count=$((anomaly_count + 1))
|
||
fi
|
||
|
||
# No recent release
|
||
if [[ ${4:-0} -eq 0 ]]; then
|
||
anomalies="${anomalies}{\"type\":\"no_release\",\"severity\":\"Info\",\"detail\":\"无 Release 记录\"},"
|
||
anomaly_count=$((anomaly_count + 1))
|
||
fi
|
||
|
||
# Activity decline (if prev data available)
|
||
if [[ -n "${5:-}" ]] && [[ -n "${6:-}" ]]; then
|
||
local decline
|
||
decline=$(awk -v cur="${5}" -v prev="${6}" 'BEGIN { if(prev>0 && cur<prev*0.5) print "1"; else print "0" }')
|
||
if [[ "$decline" == "1" ]]; then
|
||
anomalies="${anomalies}{\"type\":\"activity_decline\",\"severity\":\"Warning\",\"detail\":\"活动量下降超过 50%\"},"
|
||
anomaly_count=$((anomaly_count + 1))
|
||
fi
|
||
fi
|
||
|
||
# CI failure
|
||
if [[ -n "${7:-}" ]] && [[ -n "${8:-}" ]] && [[ "${7}" -gt 0 ]] && [[ "${8}" -gt 0 ]]; then
|
||
local ci_fail_rate
|
||
ci_fail_rate=$(awk -v ok="${7}" -v t="${8}" 'BEGIN { if(t>0 && ok/t<0.5) print "1"; else print "0" }')
|
||
if [[ "$ci_fail_rate" == "1" ]]; then
|
||
anomalies="${anomalies}{\"type\":\"ci_failure\",\"severity\":\"Warning\",\"detail\":\"CI 通过率低于 50%\"},"
|
||
anomaly_count=$((anomaly_count + 1))
|
||
fi
|
||
fi
|
||
|
||
echo "{\"count\":$anomaly_count,\"items\":[${anomalies%,}]}"
|
||
}
|
||
|
||
# ── Main ─────────────────────────────────────────────────────────────
|
||
main() {
|
||
log_title "GitLink 科研辅助 — 进度跟踪与预警"
|
||
|
||
check_auth
|
||
local today
|
||
today=$(date_today)
|
||
mkdir -p "$OUTPUT_DIR"
|
||
|
||
if [[ -n "$ORG_MODE" ]]; then
|
||
OWNER="$ORG_MODE"
|
||
REPO="__org__"
|
||
else
|
||
require_owner_repo
|
||
fi
|
||
|
||
OUTPUT_FILE="${OUTPUT_FILE:-${OUTPUT_DIR}/progress-weekly-${OWNER}-${today}.html}"
|
||
|
||
# ═══ Data Collection ═══
|
||
log_step "1/5 收集 Issue 数据..."
|
||
local open_json closed_json
|
||
open_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100)
|
||
closed_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100)
|
||
|
||
local total_open=0 total_closed=0
|
||
if [[ "$(json_ok "$open_json")" == "true" ]]; then
|
||
total_open=$(echo "$open_json" | jq -r '(.data.issues // .data | if type == "array" then length else 0 end)' 2>/dev/null)
|
||
fi
|
||
if [[ "$(json_ok "$closed_json")" == "true" ]]; then
|
||
total_closed=$(echo "$closed_json" | jq -r '(.data.issues // .data | if type == "array" then length else 0 end)' 2>/dev/null)
|
||
fi
|
||
|
||
log_info " Issues: ${total_open} 开放 / ${total_closed} 已关闭"
|
||
|
||
# ═══ PR Data ═══
|
||
log_step "2/5 收集 PR 数据..."
|
||
local merged_json open_prs_json
|
||
merged_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100)
|
||
open_prs_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state open --limit 50)
|
||
|
||
local total_merged=0 total_open_prs=0
|
||
if [[ "$(json_ok "$merged_json")" == "true" ]]; then
|
||
total_merged=$(echo "$merged_json" | jq -r '(.data.issues // .data.pulls // .data | if type == "array" then length else 0 end)' 2>/dev/null)
|
||
fi
|
||
if [[ "$(json_ok "$open_prs_json")" == "true" ]]; then
|
||
total_open_prs=$(echo "$open_prs_json" | jq -r '(.data.issues // .data.pulls // .data | if type == "array" then length else 0 end)' 2>/dev/null)
|
||
fi
|
||
|
||
log_info " PRs: ${total_open_prs} 开放 / ${total_merged} 已合并"
|
||
|
||
# ═══ Release Data ═══
|
||
log_step "3/5 收集 Release 数据..."
|
||
local release_json release_count=0 last_release_date=""
|
||
release_json=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 20)
|
||
if [[ "$(json_ok "$release_json")" == "true" ]]; then
|
||
release_count=$(echo "$release_json" | jq -r '(.data.releases | length) // 0' 2>/dev/null)
|
||
last_release_date=$(echo "$release_json" | jq -r '.data.releases[0].created_at // ""' 2>/dev/null)
|
||
if [[ -n "$last_release_date" ]]; then
|
||
last_release_date="${last_release_date:0:10}"
|
||
fi
|
||
fi
|
||
log_info " Releases: ${release_count}"
|
||
|
||
# ═══ CI Data ═══
|
||
log_step "4/5 收集 CI 数据..."
|
||
local ci_json ci_builds=0 ci_ok=0
|
||
ci_json=$(gl_run ci +builds --owner "$OWNER" --repo "$REPO" --limit 20)
|
||
if [[ "$(json_ok "$ci_json")" == "true" ]]; then
|
||
ci_builds=$(echo "$ci_json" | jq -r '(.data | length) // 0' 2>/dev/null)
|
||
ci_ok=$(echo "$ci_json" | jq -r '[.data[] | select(.status == "success" or .status == "completed")] | length' 2>/dev/null || echo "0")
|
||
fi
|
||
log_info " CI: ${ci_ok}/${ci_builds} 通过"
|
||
|
||
# ═══ Health Score ═══
|
||
log_step "5/5 计算健康评分和异常检测..."
|
||
local health_score
|
||
# Provide prev period data as half of current (simplified estimation)
|
||
local prev_closed=$((total_closed / 2))
|
||
health_score=$(calc_health "$total_open" "$total_closed" "$total_merged" "$total_open_prs" \
|
||
"$release_count" "$ci_ok" "$ci_builds" "$total_open" "$prev_closed")
|
||
|
||
local anomalies_json
|
||
anomalies_json=$(detect_anomalies "$total_open" "$total_closed" "$total_open_prs" \
|
||
"$release_count" "$total_closed" "$prev_closed" "$ci_ok" "$ci_builds")
|
||
|
||
local anomaly_count
|
||
anomaly_count=$(echo "$anomalies_json" | jq -r '.count // 0' 2>/dev/null)
|
||
|
||
# ═══ Health Grade ═══
|
||
local health_label health_color
|
||
if awk "BEGIN { exit ($health_score >= 80) ? 0 : 1 }"; then
|
||
health_label="健康"; health_color="#2e7d32"
|
||
elif awk "BEGIN { exit ($health_score >= 60) ? 0 : 1 }"; then
|
||
health_label="正常"; health_color="#558b2f"
|
||
elif awk "BEGIN { exit ($health_score >= 40) ? 0 : 1 }"; then
|
||
health_label="需关注"; health_color="#f57c00"
|
||
else
|
||
health_label="风险"; health_color="#c62828"
|
||
fi
|
||
|
||
log_ok "健康评分: ${health_score}/100 (${health_label})"
|
||
if [[ $anomaly_count -gt 0 ]]; then
|
||
log_warn "检测到 ${anomaly_count} 个异常"
|
||
fi
|
||
|
||
# ═══ Generate HTML ═══
|
||
log_step "生成周报 HTML..."
|
||
|
||
# Build anomaly table rows
|
||
local anomaly_rows=""
|
||
if [[ $anomaly_count -gt 0 ]]; then
|
||
anomaly_rows=$(echo "$anomalies_json" | jq -r '.items[] | "<tr><td>\(.type)</td><td class=\"sev-\(.severity)\">\(.severity)</td><td>\(.detail)</td></tr>"' 2>/dev/null)
|
||
fi
|
||
|
||
if [[ "${DRY_RUN:-false}" != "true" ]]; then
|
||
cat > "$OUTPUT_FILE" <<HTMLEOF
|
||
<!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} — 进度周报 ${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: ${health_color}; 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} — 科研进度周报</h1>
|
||
<div style="opacity:0.8;font-size:14px;">$LOOKBACK_WEEKS 周回顾 — ${today}</div>
|
||
</div>
|
||
<div class="container">
|
||
|
||
<div class="cards">
|
||
<div class="card health">
|
||
<div class="value">${health_score}</div>
|
||
<div class="label">健康评分 / 100 — ${health_label}</div>
|
||
</div>
|
||
<div class="card"><div class="value">${total_open}</div><div class="label">开放 Issues</div></div>
|
||
<div class="card"><div class="value">${total_open_prs}</div><div class="label">开放 PRs</div></div>
|
||
<div class="card"><div class="value">${total_merged}</div><div class="label">已合并 PRs</div></div>
|
||
<div class="card"><div class="value">${release_count}</div><div class="label">Release 数</div></div>
|
||
<div class="card"><div class="value">${anomaly_count}</div><div class="label">异常信号</div></div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="panel">
|
||
<h2>Issue / PR 概览</h2>
|
||
<div id="overviewChart" class="chart"></div>
|
||
</div>
|
||
<div class="panel">
|
||
<h2>异常预警</h2>
|
||
$( if [[ $anomaly_count -eq 0 ]]; then
|
||
echo "<div style=\"text-align:center;padding:40px;color:#2e7d32;\"><b>未检测到异常,项目运行良好</b></div>"
|
||
else
|
||
echo "<table><tr><th>类型</th><th>严重度</th><th>详情</th></tr>${anomaly_rows}</table>"
|
||
fi )
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<h2>进度指标明细</h2>
|
||
<table>
|
||
<tr><th>指标</th><th>数值</th><th>趋势</th><th>建议</th></tr>
|
||
<tr>
|
||
<td>Issue 流速</td>
|
||
<td>${total_open} 开放 / ${total_closed} 关闭</td>
|
||
<td>$( [[ $total_closed -gt $total_open ]] && echo "<span class=\"ok\">↑ 改善</span>" || echo "<span class=\"warn\">↓ 积压</span>")</td>
|
||
<td>$( [[ $total_open -gt $total_closed ]] && echo "建议安排 Issue 清理日" || echo "—")</td>
|
||
</tr>
|
||
<tr>
|
||
<td>PR 合并率</td>
|
||
<td>$(awk -v m="$total_merged" -v o="$total_open_prs" 'BEGIN { t=m+o; printf "%.0f%%", (t>0?m/t*100:0) }')</td>
|
||
<td>$( [[ $total_open_prs -le 5 ]] && echo "<span class=\"ok\">正常</span>" || echo "<span class=\"warn\">积压</span>")</td>
|
||
<td>$( [[ $total_open_prs -gt 5 ]] && echo "建议增加 Code Review 频率" || echo "—")</td>
|
||
</tr>
|
||
<tr>
|
||
<td>发布节奏</td>
|
||
<td>${release_count} 个 Release</td>
|
||
<td>$( [[ $release_count -ge 3 ]] && echo "<span class=\"ok\">活跃</span>" || echo "<span class=\"warn\">不活跃</span>")</td>
|
||
<td>$( [[ $release_count -eq 0 ]] && echo "建议发布 v0.1.0 初始版本" || echo "—")</td>
|
||
</tr>
|
||
<tr>
|
||
<td>CI 稳定性</td>
|
||
<td>$(awk -v o="$ci_ok" -v t="$ci_builds" 'BEGIN { printf "%.0f%%", (t>0?o/t*100:0) }') (${ci_ok}/${ci_builds})</td>
|
||
<td>$( awk "BEGIN { if (${ci_builds}>0 && ${ci_ok}/${ci_builds}>=0.8) print \"<span class=\\\"ok\\\">稳定</span>\"; else print \"<span class=\\\"warn\\\">待改进</span>\" }" )</td>
|
||
<td>$( [[ $ci_builds -eq 0 ]] && echo "建议配置 GitLink CI" || echo "—")</td>
|
||
</tr>
|
||
<tr>
|
||
<td>最近 Release</td>
|
||
<td>${last_release_date:-无}</td>
|
||
<td>$( [[ -n "$last_release_date" ]] && echo "<span class=\"ok\">已发布</span>" || echo "<span class=\"warn\">无记录</span>")</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: ['开放', '已完成'] },
|
||
xAxis: { type: 'category', data: ['Issues', 'Pull Requests', 'Releases', 'CI Builds'] },
|
||
yAxis: { type: 'value' },
|
||
series: [
|
||
{ name: '开放', type: 'bar', data: [${total_open}, ${total_open_prs}, 0, 0], itemStyle: { color: '#fac858' } },
|
||
{ name: '已完成', type: 'bar', data: [${total_closed}, ${total_merged}, ${release_count}, ${ci_builds}], itemStyle: { color: '#91cc75' } }
|
||
]
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
HTMLEOF
|
||
log_ok "周报已生成: $OUTPUT_FILE"
|
||
fi
|
||
|
||
# ═══ Summary ═══
|
||
echo ""
|
||
divider
|
||
log_title "进度周报摘要"
|
||
echo " 仓库: ${OWNER}/${REPO}"
|
||
echo " 健康评分: ${health_score}/100 (${health_label})"
|
||
echo " Issues: ${total_open} 开放 / ${total_closed} 已关闭"
|
||
echo " PRs: ${total_open_prs} 开放 / ${total_merged} 已合并"
|
||
echo " Releases: ${release_count}"
|
||
echo " CI: ${ci_ok}/${ci_builds} 通过"
|
||
echo " 异常数: ${anomaly_count}"
|
||
if [[ $anomaly_count -gt 0 ]]; then
|
||
echo "$anomalies_json" | jq -r '.items[] | " [\(.severity)] \(.detail)"' 2>/dev/null
|
||
fi
|
||
divider
|
||
}
|
||
|
||
main "$@"
|