Sentry 每日错误巡检自动化:设计思路与上手实战
本文面向项目开发、测试与值班同学,目标是把"每天看 Sentry"从人工动作升级为稳定、可复用的自动化流程。
脚本入口:script/sentry-triage.js (sentry-triage.js见文章结尾)
运行命令:npm run sentry:triage -- --days-ago=1 (package.json中 scripts增加"sentry:triage": "node ./script/sentry-triage.js",)
1. 这套自动化解决了什么问题
日常人工巡检 Sentry 常见痛点:
- 问题多、筛选慢,容易漏高影响错误
- 每天查看口径不统一,难横向对比
- 发现问题后,定位方向和修复动作不够标准化
本脚本的目标:
- 每天固定时间窗巡检(按自然日)
- 自动拉取问题并按影响面排序
- 自动生成 Markdown + JSON 报告落盘
- 自动推送企业微信摘要,第一时间提醒
2. 整体设计思路(基于当前实现)
2.1 数据来源与查询窗口
- 查询目标:
/api/0/projects/{orgSlug}/{projectSlug}/issues/ - 默认巡检"昨天"自然日(
--days-ago=1) - 支持指定日期巡检(
--date=YYYY-MM-DD) - 时区可配,默认
Asia/Shanghai
2.2 为什么做"两次查询 + 本地合并"
脚本分别按 lastSeen 和 firstSeen 查询后再本地合并,原因是兼容较老版本 Sentry(避免依赖复杂布尔查询能力)。
- 第一次:
lastSeen落在窗口内的问题 - 第二次:
firstSeen落在窗口内的问题 - 合并策略:按 issue 主键去重,保留计数更高的一条
这样能兼顾"当天新出现问题"和"当天仍在活跃的问题"。
2.3 排序策略(谁先修)
脚本会给每个问题打优先级分:
priorityScore = users + events + unresolvedBonus + regressedBonus
users:影响用户数events:事件量unresolvedBonus:未解决问题加权regressedBonus:回归问题额外加权
最终报告按分数降序,默认展开 Top 10。
2.4 巡检不只"列问题",还给修复动作
每条问题会自动补充:
- 可疑模块(
suspectArea) - 根因提示(
rootCauseHint) - 建议改动点(
fixSuggestion) - 验证清单(
verifyChecklist)
这些内容能直接作为"今日修复清单"的骨架,提高交付速度。
2.5 可靠性与失败分支
脚本内置容错机制:
- API 请求超时:
15s - 重试次数:最多 2 次(指数递增等待)
- 5xx、超时、网络异常会重试
- 如果查询失败,会生成"兜底报告"(报告中带
执行异常),避免流程静默失败 - 企业微信通知失败会输出错误并置非 0 退出码,方便 CI/定时任务感知失败
3. 配置清单
3.1 必填环境变量
SENTRY_ORG_SLUG:Sentry 组织 slugSENTRY_PROJECT_SLUG:Sentry 项目 slugSENTRY_AUTH_TOKEN:Sentry API Token
3.2 可选环境变量
SENTRY_BASE_URL:默认https://sentry.ioSENTRY_ENVIRONMENT:默认productionSENTRY_TZ:默认Asia/ShanghaiSENTRY_TRIAGE_OUTPUT_DIR:默认./tmp/sentry-reportsSENTRY_USE_PROXY:默认false,true时使用系统代理SENTRY_WECOM_WEBHOOK_URL:企业微信机器人 Webhook
注意:当前脚本内对 SENTRY_WECOM_WEBHOOK_URL 有内置默认值。为了避免消息发错群,建议在部署环境中显式配置该变量。
3.3 建议的本地配置方式
可以在本地 shell profile 或 CI Secret 中配置,例如:
bash
export SENTRY_ORG_SLUG="your-org"
export SENTRY_PROJECT_SLUG="your-project"
export SENTRY_AUTH_TOKEN="sntrys_xxx"
export SENTRY_ENVIRONMENT="production"
export SENTRY_TZ="Asia/Shanghai"
export SENTRY_TRIAGE_OUTPUT_DIR="./tmp/sentry-reports"
export SENTRY_USE_PROXY="false"
export SENTRY_WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-key"
4. 如何运行
4.1 快速试跑(默认巡检昨天)
bash
npm run sentry:triage -- --days-ago=1
4.2 指定日期回放
bash
npm run sentry:triage -- --date=2026-03-30 --top=20
4.3 查看帮助
bash
npm run sentry:triage -- --help
4.4 产物说明
执行后会在输出目录生成:
sentry-triage-YYYY-MM-DD.md:给人读的巡检报告sentry-triage-YYYY-MM-DD.json:给系统处理的结构化数据
默认目录:tmp/sentry-reports/
5. 每日自动化实战(cron 示例)
利用 codex 自动化实现每日定时拉取 sentry 日志,改 bug
6. 一次完整上手流程(团队可直接照做)
Step 1:准备权限与参数
- 在 Sentry 创建 API Token
- 确认组织和项目 slug
- 配置企业微信群机器人(可选但推荐)
Step 2:本地验证
执行:
bash
npm run sentry:triage -- --days-ago=1 --top=10
检查:
- 控制台是否输出报告摘要
tmp/sentry-reports/下是否有.md与.json- 企业微信群是否收到巡检摘要
Step 3:建立值班处理节奏
建议每天按下面节奏处理:
- 先看报告 Top N,按优先级分配责任人
- 每条 issue 按"建议改动点 + 验证清单"执行
- 修复后观察下一天巡检结果,确认不再新增
7. 常见问题排查
Q1:报错"缺少必填环境变量"
原因:SENTRY_ORG_SLUG / SENTRY_PROJECT_SLUG / SENTRY_AUTH_TOKEN 未配置。
处理:补齐环境变量后重跑。
Q2:报错 HTTP 401/403
原因:Token 无效或权限不足。
处理:重新生成 Token,确认具备读取项目 issue 的权限。
Q3:企业微信通知失败
原因:Webhook 无效、机器人被禁用或网络不通。
处理:更新 SENTRY_WECOM_WEBHOOK_URL 并验证网络可达。
Q4:公司网络需要代理
处理:设置 SENTRY_USE_PROXY=true,使用系统代理访问 Sentry。
8. 推荐最佳实践
- 用
--date做历史回放,验证"修复前后"问题趋势 - 报告 JSON 进入二次处理(如飞书/看板/周报汇总)
- 将高频模块(如网络层、路由层)补齐统一失败兜底
- 保持"先止血再根因治理"的处理节奏:先避免崩溃,再做深层优化
9. 脚本与命令速查
- 脚本文件:
script/sentry-triage.js - npm 命令:
npm run sentry:triage - 示例命令:
npm run sentry:triage -- --days-ago=1 --top=10 - 帮助命令:
npm run sentry:triage -- --help
如果你希望下一步把这份巡检接入 CI(GitHub Actions/Jenkins)并自动归档报告,我建议在本仓库再补一份对应流水线模板,团队复制即可使用。
javascript
// sentry-triage.js
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
dayjs.extend(utc)
dayjs.extend(timezone)
const DEFAULT_TOP = 10
const DEFAULT_DAYS_AGO = 1
const REQUEST_TIMEOUT = 15000
const REQUEST_RETRY = 2
function printHelp() {
console.log(`
用法:
npm run sentry:triage -- --days-ago=1
npm run sentry:triage -- --date=2026-03-30
参数:
--date=YYYY-MM-DD 指定日期(自然日)
--days-ago=N 回溯 N 天(默认 1)
--top=N 报告中展开的 Top N(默认 10)
--help 查看帮助
环境变量:
SENTRY_BASE_URL Sentry 基础地址,默认 https://sentry.io
SENTRY_ORG_SLUG 组织 slug(必填)
SENTRY_PROJECT_SLUG 项目 slug(必填)
SENTRY_AUTH_TOKEN Sentry Token(必填)
SENTRY_ENVIRONMENT 环境过滤,默认 production
SENTRY_TZ 时区,默认 Asia/Shanghai
SENTRY_TRIAGE_OUTPUT_DIR 输出目录,默认 ./tmp/sentry-reports
SENTRY_USE_PROXY 是否走代理访问 Sentry,默认 false
SENTRY_WECOM_WEBHOOK_URL 企业微信机器人 Webhook 地址(配置后自动推送巡检摘要)
`)
}
function parseArgs(argv) {
const args = {
daysAgo: DEFAULT_DAYS_AGO,
top: DEFAULT_TOP
}
argv.forEach(arg => {
if (arg === '--help' || arg === '-h') {
args.help = true
return
}
if (arg.startsWith('--date=')) {
args.date = arg.replace('--date=', '').trim()
return
}
if (arg.startsWith('--days-ago=')) {
args.daysAgo = Number(arg.replace('--days-ago=', '').trim())
return
}
if (arg.startsWith('--top=')) {
args.top = Number(arg.replace('--top=', '').trim())
return
}
throw new Error(`不支持的参数: ${arg}`)
})
if (args.date && argv.some(item => item.startsWith('--days-ago='))) {
throw new Error('--date 与 --days-ago 不能同时使用')
}
if (!Number.isInteger(args.daysAgo) || args.daysAgo < 0) {
throw new Error('--days-ago 必须是 >= 0 的整数')
}
if (!Number.isInteger(args.top) || args.top <= 0) {
throw new Error('--top 必须是 > 0 的整数')
}
if (args.date && !/^\d{4}-\d{2}-\d{2}$/.test(args.date)) {
throw new Error('--date 必须是 YYYY-MM-DD 格式')
}
return args
}
function readConfig() {
return {
baseUrl: (process.env.SENTRY_BASE_URL || 'https://sentry.io').replace(
/\/$/,
''
),
orgSlug: process.env.SENTRY_ORG_SLUG,
projectSlug: process.env.SENTRY_PROJECT_SLUG,
authToken: process.env.SENTRY_AUTH_TOKEN,
environment: process.env.SENTRY_ENVIRONMENT || 'production',
timezone: process.env.SENTRY_TZ || 'Asia/Shanghai',
useProxy:
String(process.env.SENTRY_USE_PROXY || 'false').toLowerCase() === 'true',
wecomWebhookUrl:
process.env.SENTRY_WECOM_WEBHOOK_URL ||
'',
outputDir:
process.env.SENTRY_TRIAGE_OUTPUT_DIR ||
path.resolve(process.cwd(), 'tmp/sentry-reports')
}
}
function validateConfig(config) {
const missing = []
if (!config.orgSlug) missing.push('SENTRY_ORG_SLUG')
if (!config.projectSlug) missing.push('SENTRY_PROJECT_SLUG')
if (!config.authToken) missing.push('SENTRY_AUTH_TOKEN')
if (missing.length > 0) {
throw new Error(`缺少必填环境变量: ${missing.join(', ')}`)
}
}
function resolveTargetDate(args, timezoneValue) {
if (args.date) {
const parsed = dayjs.tz(args.date, 'YYYY-MM-DD', timezoneValue)
if (!parsed.isValid()) {
throw new Error(`无法解析日期: ${args.date}`)
}
return parsed.startOf('day')
}
return dayjs()
.tz(timezoneValue)
.subtract(args.daysAgo, 'day')
.startOf('day')
}
function buildIssueQuery({ startAt, endAt, environment, field }) {
const start = startAt.format('YYYY-MM-DDTHH:mm:ss')
const end = endAt.format('YYYY-MM-DDTHH:mm:ss')
return [
`environment:${environment}`,
`${field}:>=${start}`,
`${field}:<${end}`
].join(' ')
}
function parseNextCursor(linkHeader) {
if (!linkHeader) return null
const items = String(linkHeader).split(',')
const nextPart = items.find(item => item.includes('rel="next"'))
if (!nextPart) return null
if (!nextPart.includes('results="true"')) return null
const match = nextPart.match(/cursor="([^"]+)"/)
return match && match[1] ? match[1] : null
}
async function requestWithRetry(http, config) {
let lastError = null
for (let i = 0; i <= REQUEST_RETRY; i += 1) {
try {
return await http.request(config)
} catch (error) {
lastError = error
const retriable =
!error.response ||
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
(error.response.status >= 500 && error.response.status < 600)
if (!retriable || i === REQUEST_RETRY) {
throw lastError
}
await new Promise(resolve => setTimeout(resolve, (i + 1) * 500))
}
}
throw lastError
}
async function fetchIssuesByQuery(http, config, startAt, endAt, field) {
const query = buildIssueQuery({
startAt,
endAt,
environment: config.environment,
field
})
const issues = []
let cursor = null
do {
const response = await requestWithRetry(http, {
url: `/api/0/projects/${config.orgSlug}/${config.projectSlug}/issues/`,
method: 'GET',
params: {
query,
sort: 'freq',
limit: 100,
statsPeriod: undefined,
start: startAt.toISOString(),
end: endAt.toISOString(),
cursor: cursor || undefined
}
})
if (Array.isArray(response.data)) {
issues.push(...response.data)
}
cursor = parseNextCursor(response.headers.link)
} while (cursor)
return issues
}
async function fetchIssues(config, startAt, endAt) {
const http = axios.create({
baseURL: config.baseUrl,
timeout: REQUEST_TIMEOUT,
proxy: config.useProxy ? undefined : false,
headers: {
Authorization: `Bearer ${config.authToken}`
}
})
// Older Sentry versions do not support boolean query with OR/AND.
// Query lastSeen and firstSeen separately, then merge locally.
const [lastSeenIssues, firstSeenIssues] = await Promise.all([
fetchIssuesByQuery(http, config, startAt, endAt, 'lastSeen'),
fetchIssuesByQuery(http, config, startAt, endAt, 'firstSeen')
])
const mergedMap = new Map()
;[...lastSeenIssues, ...firstSeenIssues].forEach(item => {
const key = String(item.id || item.shortId || item.title || '')
if (!key) return
if (!mergedMap.has(key)) {
mergedMap.set(key, item)
return
}
const prev = mergedMap.get(key)
const prevCount = toNumber(prev && prev.count)
const nextCount = toNumber(item && item.count)
if (nextCount >= prevCount) {
mergedMap.set(key, item)
}
})
return Array.from(mergedMap.values())
}
function toNumber(value) {
const result = Number(value)
return Number.isFinite(result) ? result : 0
}
function parseBoolean(value) {
if (typeof value === 'boolean') return value
if (typeof value === 'string') return value.toLowerCase() === 'true'
if (typeof value === 'number') return value !== 0
return false
}
function inferRegressed(issue) {
const statusText = String(issue.status || '').toLowerCase()
if (statusText.includes('regress')) return true
const statusDetail = issue.statusDetails || {}
const mergedText = JSON.stringify(statusDetail).toLowerCase()
if (mergedText.includes('regress')) return true
if (toNumber(issue.regressionCount) > 0) return true
return parseBoolean(issue.isRegressed)
}
function inferResolved(issue) {
if (typeof issue.isResolved === 'boolean') return issue.isResolved
return String(issue.status || '').toLowerCase() === 'resolved'
}
function inferSuspectArea(issue) {
const pool = [
issue.culprit,
issue.title,
issue.location,
issue.permalink,
issue.metadata && issue.metadata.filename,
issue.metadata && issue.metadata.function,
issue.metadata && issue.metadata.value
]
.filter(Boolean)
.join(' | ')
const sourcePath = pool.match(/src\/[A-Za-z0-9_\-./]+\.(vue|js|ts)/)
if (sourcePath && sourcePath[0]) return sourcePath[0]
const lower = pool.toLowerCase()
if (lower.includes('telephone') || lower.includes('call')) {
return 'src/util/telephone.js / src/views/ceTelephone/*'
}
if (lower.includes('router') || lower.includes('navigation')) {
return 'src/router/* / src/permission.js'
}
if (
lower.includes('axios') ||
lower.includes('network') ||
lower.includes('timeout')
) {
return 'src/router/axios.js / src/api/*'
}
if (lower.includes('customer')) {
return 'src/views/customerManagement/*'
}
if (lower.includes('task') || lower.includes('analysis')) {
return 'src/views/taskCenter/* / src/views/analysis/*'
}
if (lower.includes('login') || lower.includes('auth')) {
return 'src/page/login/* / src/store/modules/user.js'
}
return '待人工定位(建议先看 issue 最新事件堆栈)'
}
function inferRootCauseHint(issue) {
const text = [
issue.title,
issue.culprit,
issue.metadata && issue.metadata.value
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (text.includes('undefined') || text.includes('null')) {
return '空值保护不足(对象/字段读取前缺少判空)'
}
if (text.includes('timeout') || text.includes('network')) {
return '请求失败分支处理不足(超时/异常/重试/降级)'
}
if (text.includes('permission') || text.includes('forbidden')) {
return '权限状态与页面状态未对齐(路由守卫或按钮权限)'
}
if (text.includes('chunk') || text.includes('load')) {
return '静态资源加载失败未兜底(重试与刷新提示不足)'
}
return '需结合事件堆栈与面包屑进一步确认根因'
}
function buildFixSuggestion(issueItem) {
return [
`先在 ${issueItem.suspectArea} 对应入口补充防御性判断与失败分支(超时、异常、空数据)`,
'把数据映射下沉到 methods/工具函数,模板层只消费稳定字段,减少表达式异常',
'保留原有错误处理逻辑并增强日志上下文(关键参数、用户标识、路由)'
]
}
function buildVerifyChecklist(issueItem) {
return [
'按 issue 最近事件的触发路径完整复现一次,确认修复前后行为差异',
'验证失败分支:接口超时、异常响应、空数据都不会导致页面崩溃',
`确认 Sentry 中该问题在下一次发布后不再新增,并关注 issue: ${issueItem.issueId}`
]
}
function scoreIssue(issue) {
const users = toNumber(issue.users)
const events = toNumber(issue.events)
const unresolvedBonus = issue.isResolved ? 0 : 20
const regressedBonus = issue.isRegressed ? 30 : 0
return users + events + unresolvedBonus + regressedBonus
}
function normalizeIssues(rawIssues, options) {
return rawIssues
.map(item => {
const normalized = {
issueId: String(item.id || item.shortId || ''),
title:
item.title ||
(item.metadata && item.metadata.title) ||
'Untitled Issue',
level: item.level || 'error',
events: toNumber(item.count),
users: toNumber(item.userCount),
lastSeen: item.lastSeen || '',
isResolved: inferResolved(item),
isRegressed: inferRegressed(item),
suspectArea: inferSuspectArea(item),
issueUrl:
item.permalink ||
`${options.baseUrl}/organizations/${options.orgSlug}/issues/${item.id}/`
}
normalized.rootCauseHint = inferRootCauseHint(item)
normalized.fixSuggestion = buildFixSuggestion(normalized)
normalized.verifyChecklist = buildVerifyChecklist(normalized)
normalized.priorityScore = scoreIssue(normalized)
return normalized
})
.sort((a, b) => b.priorityScore - a.priorityScore)
}
function buildMarkdownReport(meta, issues, topN, errorMessage) {
const lines = []
lines.push(`# Sentry 错误分诊报告(${meta.targetDate})`)
lines.push('')
lines.push(`- 生成时间: ${meta.generatedAt}`)
lines.push(`- 查询窗口: ${meta.windowStart} ~ ${meta.windowEnd}`)
lines.push(`- 环境: ${meta.environment}`)
lines.push(`- 总问题数: ${issues.length}`)
lines.push(`- 展开数量: Top ${Math.min(topN, issues.length)}`)
lines.push('')
if (errorMessage) {
lines.push('## 执行异常')
lines.push('')
lines.push(`- 异常信息: ${errorMessage}`)
lines.push('- 当前输出为兜底报告,自动化流程可继续,但请尽快排查配置或网络')
lines.push('')
}
if (issues.length === 0) {
lines.push('## 今日结论')
lines.push('')
lines.push('- 该时间窗口未检索到问题,或问题已全部被过滤。')
lines.push('')
return lines.join('\n')
}
lines.push('## 修复清单(影响面优先)')
lines.push('')
issues.slice(0, topN).forEach((item, index) => {
lines.push(`### ${index + 1}. [${item.title}](${item.issueUrl})`)
lines.push('')
lines.push(`- issueId: ${item.issueId}`)
lines.push(`- level: ${item.level}`)
lines.push(`- priorityScore: ${item.priorityScore}`)
lines.push(`- 影响数据: users=${item.users}, events=${item.events}`)
lines.push(`- lastSeen: ${item.lastSeen || '未知'}`)
lines.push(`- 状态: ${item.isResolved ? '已解决' : '未解决'}`)
lines.push(`- 回归: ${item.isRegressed ? '是' : '否'}`)
lines.push(`- 可疑模块: ${item.suspectArea}`)
lines.push(`- 可能根因: ${item.rootCauseHint}`)
lines.push('- 建议改动点:')
item.fixSuggestion.forEach((step, idx) => {
lines.push(` ${idx + 1}. ${step}`)
})
lines.push('- 验证清单:')
item.verifyChecklist.forEach((step, idx) => {
lines.push(` ${idx + 1}. ${step}`)
})
lines.push('')
})
return lines.join('\n')
}
function writeOutputs(reportMarkdown, reportJson, targetDate) {
const outputDir = reportJson.meta.outputDir
fs.mkdirSync(outputDir, { recursive: true })
const mdPath = path.join(outputDir, `sentry-triage-${targetDate}.md`)
const jsonPath = path.join(outputDir, `sentry-triage-${targetDate}.json`)
fs.writeFileSync(mdPath, reportMarkdown, 'utf8')
fs.writeFileSync(jsonPath, JSON.stringify(reportJson, null, 2), 'utf8')
return { mdPath, jsonPath }
}
function buildWecomSummaryMessage(meta, summary) {
return [
`- 生成时间: ${meta.generatedAt}`,
`- 查询窗口: ${meta.windowStart} ~ ${meta.windowEnd}`,
`- 环境: ${meta.environment}`,
`- 总问题数: ${summary.totalIssues}`,
`- 展开数量: Top ${summary.expandedTopN}`
].join('\n')
}
async function notifyWecom(config, meta, summary) {
if (!config.wecomWebhookUrl) {
return { skipped: true, reason: 'SENTRY_WECOM_WEBHOOK_URL 未配置' }
}
const http = axios.create({
timeout: REQUEST_TIMEOUT,
proxy: config.useProxy ? undefined : false
})
const message = buildWecomSummaryMessage(meta, summary)
const response = await requestWithRetry(http, {
url: config.wecomWebhookUrl,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
data: {
msgtype: 'text',
text: {
content: message
}
}
})
const errcode = toNumber(response && response.data && response.data.errcode)
if (errcode !== 0) {
const errmsg =
(response && response.data && response.data.errmsg) || '未知错误'
throw new Error(`企业微信通知失败(errcode=${errcode}, errmsg=${errmsg})`)
}
return { skipped: false }
}
function formatError(error) {
if (!error) return '未知错误'
if (error.response) {
const status = error.response.status
const detail =
(error.response.data && error.response.data.detail) ||
(typeof error.response.data === 'string' ? error.response.data : '')
return `请求失败(HTTP ${status})${detail ? `: ${detail}` : ''}`
}
return error.message || String(error)
}
async function main() {
let args
try {
args = parseArgs(process.argv.slice(2))
} catch (error) {
console.error(`参数错误: ${error.message}`)
printHelp()
process.exitCode = 1
return
}
if (args.help) {
printHelp()
return
}
const config = readConfig()
const target = resolveTargetDate(args, config.timezone)
const startAt = target.startOf('day')
const endAt = target.endOf('day')
const meta = {
generatedAt: dayjs()
.tz(config.timezone)
.format('YYYY-MM-DD HH:mm:ss'),
targetDate: target.format('YYYY-MM-DD'),
windowStart: startAt.format('YYYY-MM-DD HH:mm:ss'),
windowEnd: endAt.format('YYYY-MM-DD HH:mm:ss'),
environment: config.environment,
timezone: config.timezone,
outputDir: config.outputDir
}
let triagedIssues = []
let reportError = null
try {
validateConfig(config)
const rawIssues = await fetchIssues(config, startAt, endAt)
triagedIssues = normalizeIssues(rawIssues, config)
} catch (error) {
reportError = formatError(error)
}
const report = buildMarkdownReport(meta, triagedIssues, args.top, reportError)
const payload = {
meta,
summary: {
totalIssues: triagedIssues.length,
expandedTopN: Math.min(args.top, triagedIssues.length),
hasError: Boolean(reportError),
errorMessage: reportError || ''
},
issues: triagedIssues.map(issue => ({
issueId: issue.issueId,
title: issue.title,
level: issue.level,
events: issue.events,
users: issue.users,
lastSeen: issue.lastSeen,
isResolved: issue.isResolved,
isRegressed: issue.isRegressed,
suspectArea: issue.suspectArea,
fixSuggestion: issue.fixSuggestion,
verifyChecklist: issue.verifyChecklist,
issueUrl: issue.issueUrl,
priorityScore: issue.priorityScore,
rootCauseHint: issue.rootCauseHint
}))
}
const files = writeOutputs(report, payload, meta.targetDate)
console.log(report)
console.log('')
console.log(`Markdown 文件: ${files.mdPath}`)
console.log(`JSON 文件: ${files.jsonPath}`)
try {
const notifyResult = await notifyWecom(config, meta, payload.summary)
if (notifyResult.skipped) {
console.log(`企业微信通知: 已跳过(${notifyResult.reason})`)
} else {
console.log('企业微信通知: 已发送')
}
} catch (error) {
console.error(`企业微信通知失败: ${formatError(error)}`)
process.exitCode = 1
}
}
main().catch(error => {
console.error(`执行失败: ${formatError(error)}`)
process.exitCode = 1
})