Sentry 每日错误巡检自动化:设计思路与上手实战

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 为什么做"两次查询 + 本地合并"

脚本分别按 lastSeenfirstSeen 查询后再本地合并,原因是兼容较老版本 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 组织 slug
  • SENTRY_PROJECT_SLUG:Sentry 项目 slug
  • SENTRY_AUTH_TOKEN:Sentry API Token

3.2 可选环境变量

  • SENTRY_BASE_URL:默认 https://sentry.io
  • SENTRY_ENVIRONMENT:默认 production
  • SENTRY_TZ:默认 Asia/Shanghai
  • SENTRY_TRIAGE_OUTPUT_DIR:默认 ./tmp/sentry-reports
  • SENTRY_USE_PROXY:默认 falsetrue 时使用系统代理
  • 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:建立值班处理节奏

建议每天按下面节奏处理:

  1. 先看报告 Top N,按优先级分配责任人
  2. 每条 issue 按"建议改动点 + 验证清单"执行
  3. 修复后观察下一天巡检结果,确认不再新增

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
})
相关推荐
ZC跨境爬虫3 小时前
使用Claude Code开发校园交友平台前端UI全记录(含架构、坑点、登录逻辑及算法)
前端·ui·架构
慧一居士3 小时前
Vue项目中,何时使用布局、子组件嵌套、插槽 对应的使用场景,和完整的使用示例
前端·vue.js
Можно3 小时前
uni.request 和 axios 的区别?前端请求库全面对比
前端·uni-app
M ? A4 小时前
解决 VuReact 中 ESLint 规则冲突的完整指南
前端·react.js·前端框架
志栋智能4 小时前
超自动化运维的终极目标:让系统自治运行
运维·网络·人工智能·安全·自动化
Jave21084 小时前
实现全局自定义loading指令
前端·vue.js
奔跑的呱呱牛4 小时前
CSS Grid 布局参数详解(超细化版)+ 中文注释 Demo
前端·css·grid
木斯佳5 小时前
前端八股文面经大全:影刀AI前端一面(2026-04-01)·面经深度解析
前端·人工智能·沙箱·tool·ai面经
小江的记录本5 小时前
【Linux】《Linux常用命令汇总表》
linux·运维·服务器·前端·windows·后端·macos