diff --git a/.env.example b/.env.example index b333b6f..59d5846 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,11 @@ ANTHROPIC_API_KEY= # AI_MODEL=gpt-4o # 限制发送给 AI 的 diff 最大字符数(防止超大 PR 超出上下文) MAX_DIFF_LENGTH=40000 + +# ==== 可观测性(JSON-lines 结构化日志)==== +# 详细事件日志总开关(默认 true;false 时仅输出 warn/error 与调用摘要) +# HEIMDALL_LOG_ENABLED=true +# 每次审查固定一行调用摘要(默认 true,独立于 HEIMDALL_LOG_ENABLED) +# HEIMDALL_INVOCATION_LOGS=true +# 日志级别过滤(默认 info):error | warn | info | debug +# HEIMDALL_LOG_LEVEL=info diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..02fc182 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_MCP_TOKEN}" + } + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 7dfd35f..4ae1d14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Core selling point: **model freedom** — supports Claude / GPT / Gemini / local | Path | Role | | --- | --- | +| `src/observability.ts` | **JSON-lines observability module** (structured logs; Worker imports it; mirrored to `scripts/observability.js`) | | `src/review/prompt.ts` | **Heimdall persona prompt (single source)** — quality core; Worker imports it directly; changes must sync the `scripts/heimdall-review.js` copy | | `src/review/parse.ts` | LLM JSON parse + report render + loose-JSON tolerance + dedup + **labels (en/zh/bilingual)** | | `src/review/providers.ts` | AI providers (anthropic / openai / gemini + local) | @@ -25,6 +26,7 @@ Core selling point: **model freedom** — supports Claude / GPT / Gemini / local | `src/app.ts` | Probot event subscriptions (PR events + `@CoderHeimdall`) | | `worker/index.ts` | Cloudflare Worker (webhook, signature, dedup, review, status marks) | | `scripts/heimdall-review.js` | Actions-mode script (zero-dep, copied to target repo) | +| `scripts/observability.js` | Actions-mode observability mirror (CommonJS, copied with heimdall-review.js) | | `template/heimdall-review.yml` | Actions-mode workflow (copied to target repo) | | `test/` | Unit tests (node:test) | @@ -41,11 +43,12 @@ npm run worker:deploy # deploy Worker ## Core Conventions (read before changing code) 1. **Three modes share one core**: changes under `src/review/` apply to the Worker automatically (imported modules); `scripts/heimdall-review.js` is a **separate copy** — sync prompt/parse/render changes. -2. **Prompt is the single source**: `src/review/prompt.ts`. Quality is driven by it; change carefully + add tests. -3. **Default on-demand**: unset `auto_review` = no auto review; `@CoderHeimdall` only. -4. **Triple dedup**: `hasExistingReview` (review query) + `heimdall/reviewed` commit status (needs App `statuses` perm) + Worker module cache. -5. **Cloudflare gotchas**: explicit `import { Buffer }`; GitHub API needs `User-Agent`; free `waitUntil` 30s (use `thinking: { type: "disabled" }`). -6. **Report language**: `REVIEW_LANGUAGE` = `en` (default) / `zh` / `bilingual`; labels in `src/review/parse.ts`. +2. **Observability mirror**: `src/observability.ts` (TS) and `scripts/observability.js` (CommonJS) are the same logic — keep them in sync when changing the observer API/events. +3. **Prompt is the single source**: `src/review/prompt.ts`. Quality is driven by it; change carefully + add tests. +4. **Default on-demand**: unset `auto_review` = no auto review; `@CoderHeimdall` only. +5. **Triple dedup**: `hasExistingReview` (review query) + `heimdall/reviewed` commit status (needs App `statuses` perm) + Worker module cache. +6. **Cloudflare gotchas**: explicit `import { Buffer }`; GitHub API needs `User-Agent`; free `waitUntil` 30s (use `thinking: { type: "disabled" }`). +7. **Report language**: `REVIEW_LANGUAGE` = `en` (default) / `zh` / `bilingual`; labels in `src/review/parse.ts`. ## Report Structure (rendered by parse.ts) diff --git a/README.md b/README.md index 9c55dc3..a365271 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Open a PR and comment `@CoderHeimdall` (or `@heimdall`) to see the review. For a mkdir -p /.github/workflows /scripts cp template/heimdall-review.yml /.github/workflows/ cp scripts/heimdall-review.js /scripts/ +cp scripts/observability.js /scripts/ ``` ### 2. Configure AI @@ -211,6 +212,10 @@ AI_MODEL=claude-sonnet-5 | Per-provider base | `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `GEMINI_BASE_URL` | env / Variable | | **Report language** | `REVIEW_LANGUAGE = en \| zh \| bilingual` (**default en**) | env / Variable | | Diff length cap | `MAX_DIFF_LENGTH` (default 40000) | env / `wrangler.toml [vars]` | +| Observability: detailed logs | `HEIMDALL_LOG_ENABLED` (**default true**) | env / Variable / `wrangler.toml [vars]` | +| Observability: per-review summary | `HEIMDALL_INVOCATION_LOGS` (**default true**) | env / Variable / `wrangler.toml [vars]` | +| Observability: log level | `HEIMDALL_LOG_LEVEL = error \| warn \| info \| debug` (**default info**) | env / Variable / `wrangler.toml [vars]` | +| Observability: per-repo override | `observability.logs.enabled / invocation_logs` | `.github/heimdall.yml` | | Only review some files | `include: ["*.ts", ...]` | `.github/heimdall.yml` | | Exclude files | `exclude: [...]` | `.github/heimdall.yml` | | Min severity shown | `min_severity: important` | `.github/heimdall.yml` | @@ -232,10 +237,51 @@ manual_reviewers: - octocat block_on_critical: true auto_review: true # default is on-demand only + +# Per-repo observability override (defaults come from env, see §Observability) +observability: + logs: + enabled: true + invocation_logs: true ``` --- +## Observability + +Heimdall emits **JSON-lines** structured logs to stdout/console — GitHub Actions workflow logs, Cloudflare Workers Logs, or self-hosted stdout — one line per event, tied together by a per-review `reviewId`. + +**Toggles (operator default via env, per-repo override via `.github/heimdall.yml`):** + +| Env | Default | Meaning | +| --- | --- | --- | +| `HEIMDALL_LOG_ENABLED` | `true` | Master switch for detailed stage logs (`review.*`, `llm.*`) | +| `HEIMDALL_INVOCATION_LOGS` | `true` | Always-on one-line summary per review (`review.invocation`) | +| `HEIMDALL_LOG_LEVEL` | `info` | Filter: `error \| warn \| info \| debug` (affects detailed logs only) | + +**Per-repo override** (in the target repo's `.github/heimdall.yml`): + +```yaml +observability: + logs: + enabled: false # turn off detailed logs for this repo + invocation_logs: true +``` + +`warn`/`error` are **always emitted** (a failure is never hidden); `enabled: false` silences only the info/debug detail. + +**Key events** — diagnose "why was this PR skipped/failed": +- `review.skip` with `reason`: `draft_pr` · `bot_pr` · `not_auto_review` · `reviewer_not_whitelisted` · `dup_review` · `dup_cache` · `dup_status` · `missing_api_key` · `empty_diff` · `non_pr_event` · `no_trigger_comment` +- `review.error` with `reason`: `llm_error` · `parse_failed` · `post_inline_failed` +- Stage events: `review.start` → `review.config` → `review.diff` (debug) → `llm.start`/`llm.done` → `review.parse` → `review.post` → `review.complete` +- `review.invocation` — one summary line per review (outcome, `durationMs`, issue counts) + +Example line: + +```json +{"ts":"2026-08-16T02:40:00.000Z","level":"info","event":"review.skip","mode":"worker","repo":"octocat/hello-world","pr":12,"sha":"abc1234","reviewId":"h-x1y2z3","reason":"not_auto_review","msg":"默认仅按需审查,跳过自动审查"} +``` + ## Report Style ```markdown diff --git a/README.zh-CN.md b/README.zh-CN.md index c679347..afc97a8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -57,7 +57,7 @@ cp scripts/heimdall-review.js scripts/ | 定位 | 单仓库自用、快速接入 | 团队多仓库 / 产品化分发 | | 需要注册 GitHub App | 否 | 是 | | 需要服务器 | 否 | 否(Cloudflare 边缘) | -| 安装方式 | 复制 2 个文件到目标仓库 | 安装 GitHub App | +| 安装方式 | 复制 3 个文件到目标仓库 | 安装 GitHub App | | 部署成本 | 免费 | 免费额度内(大 diff 建议 Pro) | - **只想给自己的仓库加个 AI reviewer** → 模式 A,2 分钟 @@ -73,6 +73,7 @@ cp scripts/heimdall-review.js scripts/ mkdir -p <目标仓库>/.github/workflows <目标仓库>/scripts cp template/heimdall-review.yml <目标仓库>/.github/workflows/ cp scripts/heimdall-review.js <目标仓库>/scripts/ +cp scripts/observability.js <目标仓库>/scripts/ ``` ### 2. 配置 AI @@ -216,6 +217,10 @@ AI_MODEL=claude-sonnet-5 # 你的网关支持的模型 ID | 走代理网关 / 本地模型 | `AI_BASE_URL = https://<网关>` | 环境变量 / Actions Variable | | 提供方专属 base_url | `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `GEMINI_BASE_URL` | 环境变量 / Actions Variable | | diff 长度上限 | `MAX_DIFF_LENGTH`(默认 40000)| 环境变量 / `wrangler.toml [vars]` | +| 可观测:详细日志总开关 | `HEIMDALL_LOG_ENABLED`(**默认 true**)| 环境变量 / Actions Variable / `wrangler.toml [vars]` | +| 可观测:每次审查调用摘要 | `HEIMDALL_INVOCATION_LOGS`(**默认 true**)| 环境变量 / Actions Variable / `wrangler.toml [vars]` | +| 可观测:日志级别 | `HEIMDALL_LOG_LEVEL = error \| warn \| info \| debug`(**默认 info**)| 环境变量 / Actions Variable / `wrangler.toml [vars]` | +| 可观测:仓库级覆盖 | `observability.logs.enabled / invocation_logs` | `.github/heimdall.yml` | | 只审查某些文件 | `include: ["*.ts", ...]` | `.github/heimdall.yml` | | 排除某些文件 | `exclude: ["**/generated/**", ...]` | `.github/heimdall.yml` | | 只显示 ≥ 某严重度 | `min_severity: important` | `.github/heimdall.yml` | @@ -258,10 +263,51 @@ block_on_critical: true # 设为 true 时开启自动审查;默认不配置 = 仅手动触发(@CoderHeimdall) auto_review: true + +# 仓库级可观测性覆盖(默认来自环境变量,见下方「可观测性」小节) +observability: + logs: + enabled: true + invocation_logs: true ``` --- +## 可观测性 + +海姆达尔向 stdout/console 输出 **JSON-lines** 结构化日志(GitHub Actions workflow 日志、Cloudflare Workers Logs、或自托管 stdout),一行一个事件,用每次审查的 `reviewId` 关联。 + +**开关(环境变量设运维默认,`.github/heimdall.yml` 可逐仓库覆盖):** + +| 环境变量 | 默认 | 含义 | +| --- | --- | --- | +| `HEIMDALL_LOG_ENABLED` | `true` | 详细阶段日志总开关(`review.*`、`llm.*`)| +| `HEIMDALL_INVOCATION_LOGS` | `true` | 每次审查固定一行调用摘要(`review.invocation`)| +| `HEIMDALL_LOG_LEVEL` | `info` | 级别过滤:`error \| warn \| info \| debug`(只影响详细日志)| + +**仓库级覆盖**(在被审查仓库的 `.github/heimdall.yml`): + +```yaml +observability: + logs: + enabled: false # 本仓库关掉详细日志 + invocation_logs: true +``` + +`warn`/`error` **始终输出**(失败永不隐藏);`enabled: false` 只关掉 info/debug 细节。 + +**关键事件** —— 诊断「这个 PR 为什么跳过 / 失败」: +- `review.skip` + `reason`:`draft_pr` · `bot_pr` · `not_auto_review` · `reviewer_not_whitelisted` · `dup_review` · `dup_cache` · `dup_status` · `missing_api_key` · `empty_diff` · `non_pr_event` · `no_trigger_comment` +- `review.error` + `reason`:`llm_error` · `parse_failed` · `post_inline_failed` +- 阶段事件:`review.start` → `review.config` → `review.diff`(debug)→ `llm.start`/`llm.done` → `review.parse` → `review.post` → `review.complete` +- `review.invocation` —— 每次审查一行摘要(outcome、`durationMs`、各级别问题数) + +示例行: + +```json +{"ts":"2026-08-16T02:40:00.000Z","level":"info","event":"review.skip","mode":"worker","repo":"octocat/hello-world","pr":12,"sha":"abc1234","reviewId":"h-x1y2z3","reason":"not_auto_review","msg":"默认仅按需审查,跳过自动审查"} +``` + ## 审查报告样式 ```markdown diff --git a/docs/superpowers/specs/2026-08-16-observability-design.md b/docs/superpowers/specs/2026-08-16-observability-design.md new file mode 100644 index 0000000..a9d604e --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-observability-design.md @@ -0,0 +1,161 @@ +# Heimdall 可观测性设计(Observability Design) + +| 项 | 值 | +| --- | --- | +| 日期 | 2026-08-16 | +| 状态 | 已批准(待实现) | +| 目标 | 诊断审查失败与跳过(diagnose failures & skips) | +| 范围 | 三种运行时(Probot / Cloudflare Workers / GitHub Actions) | + +--- + +## 1. 背景与目标 + +当前 Heimdall 的可观测性极弱:三种运行时里散落着中文 `console.log` / `console.error`,没有时间戳、没有关联 ID、没有耗时、没有结构化数据。GitHub 状态检查(`heimdall/reviewed`、`heimdall/critical`)是唯一的机器可读信号。 + +**核心目标**:让每个「为什么跳过 / 为什么失败」都能被一条结构化日志回答。次目标:阶段耗时可见(辅助诊断慢审查)。 + +**非目标**(用户明确排除):聚合指标(usage/quality metrics)、外部导出(OTel/Datadog/webhook)、按仓库统计成本。 + +## 2. 关键决策记录 + +| 决策点 | 结论 | +| --- | --- | +| 日志格式 | **JSON-lines**,一行一个事件 | +| 覆盖模式 | **全部三种**运行时 | +| 级别控制 | 环境变量 `HEIMDALL_LOG_LEVEL`(`error\|warn\|info\|debug`,默认 `info`) | +| 实现方案 | **方案 A**:共享零依赖 observability 模块 | +| Actions 镜像 | **方案 A**:独立 `scripts/observability.js`,`heimdall-review.js` require 它(复制文件数 2 → 3) | +| 配置位置 | **两者结合**:环境变量设默认,`.github/heimdall.yml` 可逐仓库覆盖 | +| `enabled` 语义 | **详细日志默认开**;`invocation_logs` 为始终开启的调用摘要 | + +## 3. 配置模型 + +**优先级(高 → 低)**:仓库级 `.github/heimdall.yml` > 环境变量 > 默认值。 + +### 3.1 环境变量(运维默认) + +| 变量 | 默认 | 作用 | +| --- | --- | --- | +| `HEIMDALL_LOG_ENABLED` | `true` | 详细事件日志总开关 | +| `HEIMDALL_INVOCATION_LOGS` | `true` | 每条审查固定一行调用摘要 | +| `HEIMDALL_LOG_LEVEL` | `info` | 详细日志过滤级别:`error\|warn\|info\|debug` | + +### 3.2 仓库级覆盖(`.github/heimdall.yml`) + +```yaml +observability: + logs: + enabled: false # 本仓库关掉详细日志 + invocation_logs: true +``` + +### 3.3 语义 + +- `logs.enabled = true`(默认)→ 输出各阶段 info/debug 事件 +- `logs.enabled = false` → 只保留 `warn`/`error`(失败永远可见)和调用摘要 +- `invocation_logs = true`(默认)→ 每次审查固定一行 `review.invocation`(repo/pr/sha/耗时/结果/问题数),与 `enabled` 无关 +- `HEIMDALL_LOG_LEVEL` 只过滤详细日志,不影响摘要 + +**解析器约束**:手写 YAML 解析器(`src/review/repo-config.ts` 及 Actions 副本)当前不支持嵌套 map,需扩展 `observability:` 块的递归解析;`RepoConfig` 新增 `observability` 字段。Worker 的 `Env` 接口新增三个变量。 + +## 4. 共享模块与输出格式 + +**`src/observability.ts`**(零依赖,镜像到 `scripts/observability.js`): + +```ts +createObserver({ mode, enabled, invocationLogs, level }) → Observer + .info/.warn/.error/.debug(event, msg, fields?) + .start() → Span // finish(event, fields?) 自动带 durationMs + .child({ repo, pr, sha }) → Observer // 上下文绑定 +``` + +每行 JSON 格式: + +```json +{"ts":"2026-08-16T02:40:00.000Z","level":"info","event":"review.start","mode":"worker","repo":"octocat/hello-world","pr":12,"sha":"abc1234","msg":"开始审查"} +``` + +底层走 `console.log` / `console.error`:Probot 落 stdout,Worker 落 Workers Logs,Actions 落 workflow 日志。不引入任何依赖。 + +## 5. 事件目录 + +### 5.1 阶段事件 + +`review.start` → `review.config` → `review.diff` → `llm.start` → `llm.done` → `review.parse` → `review.post` → `review.complete` + +阶段事件携带:`durationMs`、`provider`、`model`、问题数(critical/important/normal)、文件数、diff 字节数(字节数细节放 debug)。 + +### 5.2 跳过事件 `review.skip`(机器可读 `reason`) + +| reason | 含义 | +| --- | --- | +| `draft_pr` | 草稿 PR | +| `bot_pr` | 机器人发起的 PR/评论 | +| `not_auto_review` | 默认仅按需审查,非自动触发 | +| `reviewer_not_whitelisted` | 触发者不在 manual_reviewers | +| `dup_review` | 同 commit 已有 review(hasExistingReview) | +| `dup_cache` | Worker 模块级缓存命中 | +| `dup_status` | 已有 heimdall/reviewed 成功状态 | +| `missing_api_key` | 未配置 AI 密钥 | +| `empty_diff` | 无可审查变更 | +| `non_pr_event` | 非 PR 事件 | +| `no_trigger_comment` | 评论未匹配触发词 | + +### 5.3 失败事件 `review.error`(`reason`) + +| reason | 含义 | +| --- | --- | +| `llm_error` | LLM 调用失败(HTTP 状态、缺 key、超时) | +| `parse_failed` | 结构化解析失败,降级为整体报告 | +| `post_inline_failed` | 行内评论发布失败,降级为整体报告 | + +### 5.4 调用摘要 `review.invocation` + +受 `invocation_logs` 控制,独立于 `enabled` 与级别。一行包含:repo / pr / sha / trigger / durationMs / outcome(posted / skipped / empty / failed / parse_fallback)/ 问题数。 + +## 6. 各运行时接线 + +| 运行时 | 接线 | +| --- | --- | +| Probot | `src/review/index.ts` 在 `runReview` 内创建 observer;`src/app.ts` 中 `runReview` **之前**的跳过也用 observer(草稿/机器人/非自动/白名单) | +| Worker | `import "../src/observability"`;`Env` 接口加三变量;替换现有 console 调用 | +| Actions | `scripts/observability.js` 镜像,`heimdall-review.js` `require`;复制文件数 2 → 3,README 快速开始同步 | + +**顺序细节**:draft/bot 等跳过发生在读取 `.github/heimdall.yml` 之前,此时仅环境变量默认生效;仓库级覆盖在配置加载后、后续事件前应用。`warn`/`error` 始终输出。 + +## 7. 测试 + +`test/observability.test.js`(node:test,跑编译后的 `lib/`): + +- JSON 行格式(可解析、含 ts/level/event/msg) +- 级别过滤(info 时 debug 被过滤;error 恒输出) +- `Span.finish` 自动带 `durationMs` +- `enabled=false` 时只出调用摘要 + warn/error +- `child()` 上下文继承 +- YAML 解析器对 `observability:` 嵌套块的支持 +- 现有 `npm test` 全部保持通过 + +## 8. 文档清单 + +- `.env.example`:加三个变量及注释 +- `README.md` / `README.zh-CN.md`:新增 Observability 小节(事件/reason 码、`observability` 块、三变量) +- `AGENTS.md`:observability 模块与 `scripts/observability.js` 镜像同步约定 +- 本文档提交至 `docs/superpowers/specs/2026-08-16-observability-design.md` + +## 9. 文件改动清单 + +**新增**: +- `src/observability.ts` +- `scripts/observability.js` +- `test/observability.test.js` +- `docs/superpowers/specs/2026-08-16-observability-design.md` + +**修改**: +- `src/review/index.ts`(接线 + 迁移 console) +- `src/app.ts`(跳过事件) +- `worker/index.ts`(接线 + Env + 迁移) +- `scripts/heimdall-review.js`(require 镜像 + 迁移) +- `template/heimdall-review.yml`(注释提及第三个文件) +- `src/review/repo-config.ts` + Actions 副本(YAML 嵌套解析 + `observability` 字段) +- `README.md` / `README.zh-CN.md` / `.env.example` / `AGENTS.md` diff --git a/scripts/heimdall-review.js b/scripts/heimdall-review.js index c774676..887ec4a 100644 --- a/scripts/heimdall-review.js +++ b/scripts/heimdall-review.js @@ -11,6 +11,7 @@ "use strict"; const fs = require("fs"); +const { createObserver, resolveObserverOptions, applyLogOverrides, newReviewId } = require("./observability"); const { GITHUB_TOKEN, @@ -34,6 +35,23 @@ const LANGUAGE = ["en", "zh", "bilingual"].includes((REVIEW_LANGUAGE || "").toLo ? (REVIEW_LANGUAGE || "").toLowerCase() : "en"; +/** 创建一次审查的 observer(上下文:repo/pr/trigger) */ +function makeObserver(trigger) { + return createObserver(resolveObserverOptions("actions", process.env)).child({ + repo: GITHUB_REPOSITORY, + pr: pr.number, + reviewId: newReviewId(), + trigger, + }); +} + +/** 预检阶段的跳过事件(进程将退出,用临时 observer 打一行) */ +function emitSkip(reason, msg, extra) { + createObserver(resolveObserverOptions("actions", process.env)) + .child({ repo: GITHUB_REPOSITORY, reviewId: newReviewId(), trigger: "unknown" }) + .invocation("review.skip", msg, Object.assign({ reason }, extra)); +} + function buildSystemPrompt(language = LANGUAGE) { const directive = { en: "\n\n【输出语言】\nUse English for all output (summary, comment, suggestion).", @@ -129,17 +147,17 @@ const event = JSON.parse(fs.readFileSync(GITHUB_EVENT_PATH, "utf8")); // pull_request 事件与 issue_comment(PR 评论 @heimdall review)事件都支持 const pr = event.pull_request || (event.issue?.pull_request ? { number: event.issue.number } : null); if (!pr) { - console.log("非 PR 事件,跳过"); + emitSkip("non_pr_event", "非 PR 事件,跳过"); process.exit(0); } if (event.issue) { const body = event.comment?.body ?? ""; if (!/@(?:coder)?heimdall(?:\s+review)?\b/i.test(body)) { - console.log("非触发评论,跳过"); + emitSkip("no_trigger_comment", "非触发评论,跳过"); process.exit(0); } if (event.comment?.user?.type === "Bot") { - console.log("机器人评论,跳过"); + emitSkip("bot_pr", "机器人评论,跳过"); process.exit(0); } } @@ -157,7 +175,7 @@ const requiredKey = : ANTHROPIC_API_KEY); if (!requiredKey) { const keyName = AI_API_KEY ? "AI_API_KEY" : provider === "openai" ? "OPENAI_API_KEY" : provider === "gemini" ? "GEMINI_API_KEY" : "ANTHROPIC_API_KEY"; - console.log(`海姆达尔:未配置 ${keyName},本次跳过审查。`); + emitSkip("missing_api_key", `未配置 ${keyName},本次跳过审查`, { key: keyName }); console.log("提示:请在仓库 Settings → Secrets and variables → Actions 添加对应密钥后启用。"); process.exit(0); } @@ -320,15 +338,20 @@ async function postReview(body) { } async function main() { + let obs = makeObserver(event.issue ? "manual" : "auto"); + const reviewSpan = obs.start(); + obs.info("review.start", "开始审查"); + // 1. 读取配置、diff 与变更统计 const repoConfig = await loadRepoConfig(); + obs = applyLogOverrides(obs, repoConfig.observability && repoConfig.observability.logs); if (event.issue && !isAllowedManualReviewer(repoConfig.manual_reviewers, event.comment?.user?.login)) { - console.log("海姆达尔:评论者不在 manual_reviewers 白名单,忽略触发"); + obs.invocation("review.skip", "评论者不在 manual_reviewers 白名单,忽略触发", { reason: "reviewer_not_whitelisted", author: event.comment?.user?.login }); return; } // 默认仅按需审查:auto_review 未显式设为 true 时,PR 事件跳过自动审查(仅 @CoderHeimdall 触发) if (!event.issue && repoConfig.auto_review !== true) { - console.log("海姆达尔:默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)"); + obs.invocation("review.skip", "默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)", { reason: "not_auto_review" }); return; } // 同 commit 去重:自动或手动触发时,该 commit 已审查过则跳过,避免重复审查刷屏 @@ -338,16 +361,17 @@ async function main() { const prData = await gh(`/repos/${owner}/${repo}/pulls/${pr.number}`); headSha = prData.head?.sha; } catch (err) { - console.log("海姆达尔:获取 PR head 失败,跳过去重:", err.message); + obs.warn("review.skip", "获取 PR head 失败,跳过去重", { reason: "head_fetch_failed", detail: err.message }); } } + if (headSha) obs = obs.child({ sha: headSha }); if (headSha) { const existing = await gh(`/repos/${owner}/${repo}/pulls/${pr.number}/reviews?per_page=100`); const reviewed = existing.some( (r) => r.commit_id === headSha && (r.body || "").includes("海姆达尔") ); if (reviewed) { - console.log("海姆达尔:该 commit 已审查过,跳过重复审查"); + obs.invocation("review.skip", "该 commit 已审查过,跳过重复审查", { reason: "dup_review" }); return; } } @@ -355,10 +379,11 @@ async function main() { const reviewable = filterFiles(files, repoConfig); const stats = diffStats(reviewable); const diff = formatSafeDiff(reviewable, Number(MAX_DIFF_LENGTH)); + obs.debug("review.diff", "读取变更", { files: stats.files, additions: stats.additions, deletions: stats.deletions, diffBytes: diff.length }); if (!diff.trim()) { await postReview(renderReport(stats, "", undefined, LANGUAGE, LABELS[LANGUAGE]?.noChange)); - console.log("海姆达尔:无可审查变更"); + obs.invocation("review.invocation", "无可审查变更", { outcome: "empty", durationMs: reviewSpan.elapsed() }); return; } @@ -367,30 +392,39 @@ async function main() { : buildSystemPrompt(LANGUAGE); // 2. 调用 LLM + const llmSpan = obs.start(); let rawReport; + let outcome = "posted"; try { rawReport = await generateReview(diff, systemPrompt); + llmSpan.finish("llm.done", { provider, model: AI_MODEL, status: "ok" }); } catch (err) { + outcome = "failed"; + obs.error("review.error", `LLM 调用失败:${err.message}`, { reason: "llm_error", provider, model: AI_MODEL, durationMs: llmSpan.elapsed() }); await postReview(renderReport(stats, `⚠️ ${LABELS[LANGUAGE]?.reviewFailed}:${err.message}`, undefined, LANGUAGE)); - console.error("审查失败:", err.message); + obs.invocation("review.invocation", "审查失败", { outcome: "failed", reason: "llm_error", durationMs: reviewSpan.elapsed() }); process.exit(1); } // 3. 解析结构化结果,行内评论失败时降级为整体报告 const result = parseReview(rawReport); if (!result) { - console.log("海姆达尔:结构化解析失败,降级为整体报告"); + outcome = "parse_fallback"; + obs.warn("review.parse", "结构化解析失败,降级为整体报告", { status: "fallback" }); await postReview(renderReport(stats, rawReport, undefined, LANGUAGE)); + obs.invocation("review.invocation", "解析失败,降级为整体报告", { outcome: "parse_fallback", durationMs: reviewSpan.elapsed() }); return; } + obs.info("review.parse", "审查结果解析成功", { status: "ok", issues: result.issues.length }); const filtered = filterByMinSeverity(result, repoConfig.min_severity); filtered.issues = validateIssueLines(filtered.issues, reviewable); + const counts = { critical: 0, important: 0, normal: 0 }; + for (const i of filtered.issues) counts[i.severity]++; // block_on_critical:存在 critical 时设置状态阻断合并,无则置成功 if (repoConfig.block_on_critical) { if (headSha) { - const criticalCount = filtered.issues.filter((i) => i.severity === "critical").length; - await setCriticalStatus(headSha, criticalCount); + await setCriticalStatus(headSha, counts.critical); } } @@ -419,7 +453,7 @@ async function main() { try { await postReviewWithComments(body, comments); } catch (err) { - console.error("行内评论发布失败,降级为整体报告:", err.message); + obs.error("review.error", `行内评论发布失败,降级为整体报告:${err.message}`, { reason: "post_inline_failed" }); await postReview(body); } } @@ -436,7 +470,8 @@ async function main() { } } - console.log("海姆达尔审查完成"); + obs.info("review.post", "审查已发布", counts); + obs.invocation("review.invocation", "审查完成", { outcome: "posted", durationMs: reviewSpan.elapsed(), ...counts }); } async function loadRepoConfig() { @@ -450,48 +485,69 @@ async function loadRepoConfig() { } function parseHeimdallConfig(text) { - const cfg = {}; const lines = text.split(/\r?\n/); - let i = 0; + const parsed = parseObject(lines, 0, -1); + return parsed.value || {}; +} + +function lineIndent(line) { + const m = /^(\s*)\S/.exec(line); + return m ? m[1].length : -1; +} + +function parseObject(lines, start, parentIndent) { + const obj = {}; + let i = start; while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); + const trimmed = lines[i].trim(); if (!trimmed || trimmed.startsWith("#")) { i++; continue; } + const indent = lineIndent(lines[i]); + if (indent <= parentIndent) break; const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(trimmed); if (!match) { i++; continue; } const key = match[1]; const rest = match[2].trim(); + i++; if (rest === "|") { const block = []; - i++; - while (i < lines.length && lines[i].startsWith(" ") && lines[i].trim() !== "") { + while (i < lines.length && lines[i].trim() !== "" && lineIndent(lines[i]) > indent) { block.push(lines[i].replace(/^\s+/, "")); i++; } - if (block.length) cfg[key] = block.join("\n"); + if (block.length) obj[key] = block.join("\n"); continue; } if (rest.startsWith("[")) { const inner = rest.slice(1, rest.lastIndexOf("]")); - cfg[key] = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); - i++; + obj[key] = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); continue; } if (rest === "") { - const items = []; - i++; - while (i < lines.length && /^\s*-/.test(lines[i])) { - items.push(lines[i].replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, "")); - i++; + if (i < lines.length) { + const nIndent = lineIndent(lines[i]); + if (nIndent > indent && /^\s*-/.test(lines[i])) { + const items = []; + while (i < lines.length && lineIndent(lines[i]) > indent && /^\s*-/.test(lines[i])) { + items.push(lines[i].replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, "")); + i++; + } + obj[key] = items; + continue; + } + if (nIndent > indent && /^[A-Za-z_][\w-]*:/.test(lines[i].trim())) { + const sub = parseObject(lines, i, indent); + obj[key] = sub.value; + i = sub.next; + continue; + } } - if (items.length) cfg[key] = items; else i++; + obj[key] = undefined; continue; } - cfg[key] = parseScalar(rest); - i++; + obj[key] = parseScalar(rest); } - return cfg; + return { value: obj, next: i }; } function parseScalar(raw) { diff --git a/scripts/observability.js b/scripts/observability.js new file mode 100644 index 0000000..d3e4c1c --- /dev/null +++ b/scripts/observability.js @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * 海姆达尔 (Heimdall) 可观测性模块 —— GitHub Actions 模式镜像 + * + * 与 src/observability.ts 同逻辑(CommonJS 零依赖),改动需同步。 + * 由 scripts/heimdall-review.js require 使用,随其一起复制到被审查的仓库。 + */ +"use strict"; + +const LEVEL_RANK = { error: 0, warn: 1, info: 2, debug: 3 }; + +/** 从 process.env 解析默认配置 */ +function resolveObserverOptions(mode, env) { + const levelRaw = String((env && env.HEIMDALL_LOG_LEVEL) || "info").toLowerCase(); + const level = levelRaw in LEVEL_RANK ? levelRaw : "info"; + return { + mode, + enabled: (env && env.HEIMDALL_LOG_ENABLED) !== "false", + invocationLogs: (env && env.HEIMDALL_INVOCATION_LOGS) !== "false", + level, + }; +} + +/** 应用仓库级 observability 覆盖(shape: { enabled?, invocation_logs? }),未配置时保持原样 */ +function applyLogOverrides(obs, overrides) { + if (!overrides) return obs; + if (overrides.enabled === undefined && overrides.invocation_logs === undefined) return obs; + return obs.apply({ enabled: overrides.enabled, invocationLogs: overrides.invocation_logs }); +} + +function createObserver(options) { + const mode = options.mode; + const enabled = options.enabled !== false; + const invocationLogs = options.invocationLogs !== false; + const level = options.level || "info"; + const context = options.context || {}; + + function shouldEmit(lvl) { + return LEVEL_RANK[lvl] <= LEVEL_RANK[level]; + } + + function emit(lvl, event, msg, fields) { + const line = Object.assign({ ts: new Date().toISOString(), level: lvl, event, mode }, context, fields, { msg }); + if (lvl === "error") console.error(JSON.stringify(line)); + else console.log(JSON.stringify(line)); + } + + function emitInfo(event, msg, fields) { + if (enabled && shouldEmit("info")) emit("info", event, msg, fields); + } + + function start() { + const started = Date.now(); + return { + finish: (event, fields) => emitInfo(event, "", Object.assign({}, fields, { durationMs: Date.now() - started })), + elapsed: () => Date.now() - started, + }; + } + + return { + get level() { + return level; + }, + get enabled() { + return enabled; + }, + get invocationLogs() { + return invocationLogs; + }, + child(next) { + return createObserver({ mode, enabled, invocationLogs, level, context: Object.assign({}, context, next) }); + }, + apply(overrides) { + return createObserver({ + mode, + enabled: overrides.enabled !== undefined ? overrides.enabled : enabled, + invocationLogs: overrides.invocationLogs !== undefined ? overrides.invocationLogs : invocationLogs, + level: overrides.level || level, + context: Object.assign({}, context, overrides.context), + }); + }, + start, + info: (event, msg, fields) => emitInfo(event, msg, fields), + debug: (event, msg, fields) => { + if (enabled && shouldEmit("debug")) emit("debug", event, msg, fields); + }, + warn: (event, msg, fields) => { + if (shouldEmit("warn")) emit("warn", event, msg, fields); + }, + error: (event, msg, fields) => emit("error", event, msg, fields), + invocation: (event, msg, fields) => { + if (invocationLogs) emit("info", event, msg, fields); + }, + }; +} + +/** 生成一次审查的关联 ID */ +function newReviewId() { + return "h-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +module.exports = { createObserver, resolveObserverOptions, applyLogOverrides, newReviewId }; diff --git a/src/app.ts b/src/app.ts index 4dc8225..eb8f9f4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import { Probot } from "probot"; -import { runReview } from "./review"; +import { createObserver, newReviewId, resolveObserverOptions } from "./observability"; +import { applyRepoObservability, runReview } from "./review"; import { loadRepoConfigFromOctokit } from "./review/repo-config"; export function createApp(app: Probot): void { @@ -8,15 +9,33 @@ export function createApp(app: Probot): void { async (context) => { const pr = context.payload.pull_request; + const obs = createObserver(resolveObserverOptions("probot", process.env)).child({ + repo: `${pr.base.repo.owner.login}/${pr.base.repo.name}`, + pr: pr.number, + sha: pr.head?.sha, + reviewId: newReviewId(), + trigger: "auto", + }); + // 跳过草稿 PR 与机器人发起的 PR,避免干扰 - if (pr.draft) return; - if (pr.user?.type === "Bot") return; + if (pr.draft) { + obs.invocation("review.skip", "草稿 PR,跳过审查", { reason: "draft_pr" }); + return; + } + if (pr.user?.type === "Bot") { + obs.invocation("review.skip", "机器人发起的 PR,跳过审查", { reason: "bot_pr" }); + return; + } const owner = pr.base.repo.owner.login; const repo = pr.base.repo.name; const repoConfig = await loadRepoConfigFromOctokit(context.octokit, owner, repo); if (repoConfig.auto_review !== true) { - console.log("海姆达尔:默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)"); + applyRepoObservability(obs, repoConfig).invocation( + "review.skip", + "默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)", + { reason: "not_auto_review" } + ); return; } @@ -40,9 +59,19 @@ export function createApp(app: Probot): void { const owner = payload.repository.owner.login; const repo = payload.repository.name; + const obs = createObserver(resolveObserverOptions("probot", process.env)).child({ + repo: `${owner}/${repo}`, + pr: payload.issue.number, + reviewId: newReviewId(), + trigger: "manual", + }); const repoConfig = await loadRepoConfigFromOctokit(context.octokit, owner, repo); if (!isAllowedManualReviewer(repoConfig.manual_reviewers, payload.comment?.user?.login)) { - console.log(`海姆达尔:@${payload.comment?.user?.login} 不在 manual_reviewers 白名单,忽略触发`); + applyRepoObservability(obs, repoConfig).invocation( + "review.skip", + `@${payload.comment?.user?.login} 不在 manual_reviewers 白名单,忽略触发`, + { reason: "reviewer_not_whitelisted", author: payload.comment?.user?.login } + ); return; } diff --git a/src/observability.ts b/src/observability.ts new file mode 100644 index 0000000..7b31c4d --- /dev/null +++ b/src/observability.ts @@ -0,0 +1,164 @@ +/** + * Heimdall 可观测性模块(零依赖,JSON-lines 结构化日志) + * + * 三种运行时共用: + * - Probot(Node):import 本模块 + * - Cloudflare Workers:import 本模块(仅依赖 console/Date/JSON/Math,nodejs_compat 无需额外包) + * - GitHub Actions:scripts/observability.js 为同逻辑的 CommonJS 镜像,改动需同步 + * + * 门控规则: + * - info/debug 详细事件:受 enabled 与 level 双重控制 + * - warn/error:不受 enabled 控制(失败永远可见),warn 受 level 控制,error 恒输出 + * - invocation 调用摘要:仅受 invocationLogs 控制,与 enabled、level 无关 + */ +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const LEVEL_RANK: Record = { error: 0, warn: 1, info: 2, debug: 3 }; + +export interface ObserverContext { + repo?: string; + pr?: number; + sha?: string; + reviewId?: string; + trigger?: string; +} + +export interface ObserverOptions { + /** 运行时标识:probot | worker | actions */ + mode: string; + /** 详细事件日志总开关(默认 true) */ + enabled?: boolean; + /** 每次审查的调用摘要(默认 true,独立于 enabled/level) */ + invocationLogs?: boolean; + /** 详细日志级别过滤(默认 info) */ + level?: LogLevel; + context?: ObserverContext; +} + +export interface Span { + /** 结束计时并输出一条 info 事件(自动带 durationMs;受详细日志门控) */ + finish(event: string, fields?: Record): void; + /** 已流逝毫秒数(用于失败路径单独输出 error 事件) */ + elapsed(): number; +} + +export interface Observer { + readonly level: LogLevel; + readonly enabled: boolean; + readonly invocationLogs: boolean; + /** 派生绑定上下文的新 observer */ + child(context: ObserverContext): Observer; + /** 变更门控与上下文(用于应用仓库级覆盖) */ + apply(overrides: { enabled?: boolean; invocationLogs?: boolean; level?: LogLevel; context?: ObserverContext }): Observer; + /** 计时器 */ + start(): Span; + info(event: string, msg: string, fields?: Record): void; + warn(event: string, msg: string, fields?: Record): void; + error(event: string, msg: string, fields?: Record): void; + debug(event: string, msg: string, fields?: Record): void; + /** 调用摘要(受 invocationLogs 控制,始终 info 级) */ + invocation(event: string, msg: string, fields?: Record): void; +} + +/** 从 env(process.env 或 Worker Env)解析默认配置 */ +export function resolveObserverOptions(mode: string, env: Record): ObserverOptions { + const levelRaw = (env.HEIMDALL_LOG_LEVEL ?? "info").toLowerCase(); + const level: LogLevel = levelRaw in LEVEL_RANK ? (levelRaw as LogLevel) : "info"; + return { + mode, + enabled: env.HEIMDALL_LOG_ENABLED !== "false", + invocationLogs: env.HEIMDALL_INVOCATION_LOGS !== "false", + level, + }; +} + +/** 应用仓库级 observability 覆盖(shape: { enabled?, invocation_logs? }),未配置时保持原样 */ +export function applyLogOverrides( + obs: Observer, + overrides?: { enabled?: boolean; invocation_logs?: boolean } +): Observer { + if (!overrides) return obs; + if (overrides.enabled === undefined && overrides.invocation_logs === undefined) return obs; + return obs.apply({ enabled: overrides.enabled, invocationLogs: overrides.invocation_logs }); +} + +export function createObserver(options: ObserverOptions): Observer { + const mode = options.mode; + const enabled = options.enabled !== false; + const invocationLogs = options.invocationLogs !== false; + const level = options.level ?? "info"; + const context: ObserverContext = options.context ?? {}; + + function shouldEmit(lvl: LogLevel): boolean { + return LEVEL_RANK[lvl] <= LEVEL_RANK[level]; + } + + function emit(lvl: LogLevel, event: string, msg: string, fields?: Record): void { + const line: Record = { + ts: new Date().toISOString(), + level: lvl, + event, + mode, + ...context, + ...fields, + msg, + }; + if (lvl === "error") console.error(JSON.stringify(line)); + else console.log(JSON.stringify(line)); + } + + function emitInfo(event: string, msg: string, fields?: Record): void { + if (enabled && shouldEmit("info")) emit("info", event, msg, fields); + } + + function start(): Span { + const started = Date.now(); + return { + finish: (event, fields) => emitInfo(event, "", { ...fields, durationMs: Date.now() - started }), + elapsed: () => Date.now() - started, + }; + } + + const observer: Observer = { + get level() { + return level; + }, + get enabled() { + return enabled; + }, + get invocationLogs() { + return invocationLogs; + }, + child(next) { + return createObserver({ mode, enabled, invocationLogs, level, context: { ...context, ...next } }); + }, + apply(overrides) { + return createObserver({ + mode, + enabled: overrides.enabled ?? enabled, + invocationLogs: overrides.invocationLogs ?? invocationLogs, + level: overrides.level ?? level, + context: { ...context, ...overrides.context }, + }); + }, + start, + info: (event, msg, fields) => emitInfo(event, msg, fields), + debug: (event, msg, fields) => { + if (enabled && shouldEmit("debug")) emit("debug", event, msg, fields); + }, + warn: (event, msg, fields) => { + if (shouldEmit("warn")) emit("warn", event, msg, fields); + }, + error: (event, msg, fields) => emit("error", event, msg, fields), + invocation: (event, msg, fields) => { + if (invocationLogs) emit("info", event, msg, fields); + }, + }; + + return observer; +} + +/** 生成一次审查的关联 ID(h-<随机后缀>,足够唯一即可,非密码学用途) */ +export function newReviewId(): string { + return `h-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; +} diff --git a/src/review/index.ts b/src/review/index.ts index 62f9c91..6e47783 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -1,5 +1,6 @@ import { Context } from "probot"; import { loadConfig } from "../config"; +import { applyLogOverrides, createObserver, newReviewId, Observer, resolveObserverOptions } from "../observability"; import { generateReview } from "./providers"; import { formatSafeDiff, LABELS, parseReview, renderMarkdown, ReviewLanguage, ReviewResult, validateIssueLines } from "./parse"; import { buildSystemPrompt } from "./prompt"; @@ -19,13 +20,29 @@ export interface ReviewTarget { export async function runReview(target: ReviewTarget): Promise { const { octokit, owner, repo, pullNumber, headSha, dedupe } = target; + let obs = createObserver(resolveObserverOptions("probot", process.env)).child({ + repo: `${owner}/${repo}`, + pr: pullNumber, + sha: headSha, + reviewId: newReviewId(), + trigger: dedupe ? "auto" : "manual", + }); + const reviewSpan = obs.start(); + obs.info("review.start", "开始审查"); + if (dedupe && headSha && (await hasExistingReview(target, headSha))) { - console.log(`海姆达尔:commit ${headSha.slice(0, 8)} 已审查过,跳过重复审查`); + obs.invocation("review.skip", "该 commit 已审查过,跳过重复审查", { reason: "dup_review" }); return; } const config = loadConfig(); const repoConfig = await loadRepoConfigFromOctokit(octokit, owner, repo); + obs = applyRepoObservability(obs, repoConfig); + obs.info("review.config", "已加载仓库配置", { + autoReview: repoConfig.auto_review, + minSeverity: repoConfig.min_severity ?? null, + blockOnCritical: repoConfig.block_on_critical ?? false, + }); // 自动分页读取完整文件列表,避免 PR > 100 文件时静默遗漏 const files = await octokit.paginate(octokit.pulls.listFiles, { @@ -38,9 +55,16 @@ export async function runReview(target: ReviewTarget): Promise { const reviewable = filterFiles(files, repoConfig); const stats = diffStats(reviewable); const patch = formatSafeDiff(reviewable, config.maxDiffLength); + obs.debug("review.diff", "读取变更", { + files: stats.files, + additions: stats.additions, + deletions: stats.deletions, + diffBytes: patch.length, + }); if (!patch.trim()) { await postSummary(target, renderReport(stats, "", undefined, config.language, LABELS[config.language].noChange)); + obs.invocation("review.invocation", "无可审查变更", { outcome: "empty", durationMs: reviewSpan.elapsed() }); return; } @@ -48,32 +72,46 @@ export async function runReview(target: ReviewTarget): Promise { ? `${buildSystemPrompt(config.language)}\n\n### Team Custom Instructions / 团队自定义审查指令\n${repoConfig.instructions}` : buildSystemPrompt(config.language); + const llmSpan = obs.start(); let rawReport: string; try { rawReport = await generateReview(config, { systemPrompt, diff: patch, }); + llmSpan.finish("llm.done", { provider: config.provider, model: config.model, status: "ok" }); } catch (err) { const message = err instanceof Error ? err.message : String(err); + obs.error("review.error", `LLM 调用失败:${message}`, { + reason: "llm_error", + provider: config.provider, + model: config.model, + durationMs: llmSpan.elapsed(), + }); await postSummary(target, renderReport(stats, `⚠️ ${LABELS[config.language].reviewFailed}:${message}`, undefined, config.language)); + obs.invocation("review.invocation", "审查失败", { outcome: "failed", reason: "llm_error", durationMs: reviewSpan.elapsed() }); return; } const parsed = parseReview(rawReport); if (!parsed) { // 结构化解析失败:降级为整体报告,不静默丢失审查内容 + obs.warn("review.parse", "结构化解析失败,降级为整体报告", { status: "fallback" }); await postSummary(target, renderReport(stats, rawReport, undefined, config.language)); + obs.invocation("review.invocation", "解析失败,降级为整体报告", { outcome: "parse_fallback", durationMs: reviewSpan.elapsed() }); return; } + obs.info("review.parse", "审查结果解析成功", { status: "ok", issues: parsed.issues.length }); const filtered = filterByMinSeverity(parsed, repoConfig.min_severity); + const counts = { critical: 0, important: 0, normal: 0 }; + for (const i of filtered.issues) counts[i.severity]++; if (repoConfig.block_on_critical && headSha) { - await setCriticalStatus(target, headSha, filtered.issues.filter((i) => i.severity === "critical").length); + await setCriticalStatus(target, headSha, counts.critical); } // 校验行号:不在 diff 新增行集合的 line 归 0,避免行内评论 422 / 错位 - await postInlineReview(target, stats, { ...filtered, issues: validateIssueLines(filtered.issues, reviewable) }, config.language); + await postInlineReview(target, stats, { ...filtered, issues: validateIssueLines(filtered.issues, reviewable) }, config.language, obs); // 标记该 commit 已完成海姆达尔审查(供跨触发与跨端去重) if (headSha) { @@ -90,6 +128,14 @@ export async function runReview(target: ReviewTarget): Promise { // 忽略 status 权限不具备等失败,不影响主流程 } } + + obs.info("review.post", "审查已发布", counts); + obs.invocation("review.invocation", "审查完成", { outcome: "posted", durationMs: reviewSpan.elapsed(), ...counts }); +} + +/** 应用仓库级 observability 覆盖(未配置时保持环境变量默认) */ +export function applyRepoObservability(obs: Observer, repoConfig: RepoConfig): Observer { + return applyLogOverrides(obs, repoConfig.observability?.logs); } export function prParams(target: ReviewTarget): { @@ -199,7 +245,7 @@ ${content || noChangeMessage || ""} ${info}`; } -async function postInlineReview(target: ReviewTarget, stats: DiffStats, result: ReviewResult, language: ReviewLanguage): Promise { +async function postInlineReview(target: ReviewTarget, stats: DiffStats, result: ReviewResult, language: ReviewLanguage, obs: Observer): Promise { const L = LABELS[language]; const body = renderReport(stats, renderMarkdown(result, language), result, language); @@ -234,7 +280,8 @@ async function postInlineReview(target: ReviewTarget, stats: DiffStats, result: }); } catch (err) { // 行号映射失败(GitHub 422 等):降级为整体报告 - console.error("行内评论发布失败,降级为整体报告:", err instanceof Error ? err.message : err); + const message = err instanceof Error ? err.message : String(err); + obs.error("review.error", `行内评论发布失败,降级为整体报告:${message}`, { reason: "post_inline_failed" }); await postSummary(target, body); } } diff --git a/src/review/repo-config.ts b/src/review/repo-config.ts index 902c33d..73d66fa 100644 --- a/src/review/repo-config.ts +++ b/src/review/repo-config.ts @@ -20,105 +20,157 @@ export interface RepoConfig { block_on_critical?: boolean; /** 是否自动审查(PR 打开/更新时);设为 false 则仅响应 @heimdall review(默认 true) */ auto_review?: boolean; + /** 仓库级可观测性覆盖(默认由环境变量决定) */ + observability?: { + logs?: { + enabled?: boolean; + invocation_logs?: boolean; + }; + }; } -/** 解析 heimdall.yml(支持标量、内联数组、块列表、块文本 |,忽略其他键以兼容未来扩展) */ +/** + * 解析 heimdall.yml(支持标量、内联数组、块列表、块文本 |、嵌套 map, + * 忽略未知键以兼容未来扩展)。 + */ export function parseHeimdallConfig(text: string): RepoConfig { - const cfg: RepoConfig = {}; const lines = text.split(/\r?\n/); - let i = 0; + const parsed = parseObject(lines, 0, -1); + const cfg: RepoConfig = {}; + assignKnown(cfg, parsed.value ?? {}); + return cfg; +} + +/** 返回一行的缩进空格数(空行返回 -1) */ +function lineIndent(line: string): number { + return line.search(/\S/); +} + +/** + * 从 start 开始解析一个 map 节点,遇到缩进 <= parentIndent 的行(或结尾)停止。 + * 返回解析出的对象与下一个待处理下标。 + */ +function parseObject( + lines: string[], + start: number, + parentIndent: number +): { value: Record; next: number } { + const obj: Record = {}; + let i = start; while (i < lines.length) { - const line = lines[i]; - const trimmed = line.trim(); + const trimmed = lines[i].trim(); if (!trimmed || trimmed.startsWith("#")) { i++; continue; } + const indent = lineIndent(lines[i]); + if (indent <= parentIndent) break; + const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(trimmed); if (!match) { i++; continue; } - const key = match[1] as keyof RepoConfig; - let rest = match[2].trim(); + const key = match[1]; + const rest = match[2].trim(); + i++; if (rest === "|") { const block: string[] = []; - i++; - while (i < lines.length && lines[i].startsWith(" ") && lines[i].trim() !== "") { + while (i < lines.length && lines[i].trim() !== "" && lineIndent(lines[i]) > indent) { block.push(lines[i].replace(/^\s+/, "")); i++; } - if (block.length) setValue(cfg, key, block.join("\n")); + if (block.length) obj[key] = block.join("\n"); continue; } if (rest.startsWith("[")) { const inner = rest.slice(1, rest.lastIndexOf("]")); - const arr = inner + obj[key] = inner .split(",") .map((s) => s.trim().replace(/^["']|["']$/g, "")) .filter(Boolean); - setValue(cfg, key, arr); - i++; continue; } if (rest === "") { - const items: string[] = []; - i++; - while (i < lines.length && /^\s*-/.test(lines[i])) { - items.push(lines[i].replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, "")); - i++; - } - if (items.length) setValue(cfg, key, items); - else { - setValue(cfg, key, undefined); - i++; + if (i < lines.length) { + const nIndent = lineIndent(lines[i]); + if (nIndent > indent && /^\s*-/.test(lines[i])) { + const items: string[] = []; + while (i < lines.length && lineIndent(lines[i]) > indent && /^\s*-/.test(lines[i])) { + items.push(lines[i].replace(/^\s*-\s*/, "").trim().replace(/^["']|["']$/g, "")); + i++; + } + obj[key] = items; + continue; + } + if (nIndent > indent && /^[A-Za-z_][\w-]*:/.test(lines[i].trim())) { + const sub = parseObject(lines, i, indent); + obj[key] = sub.value; + i = sub.next; + continue; + } } + obj[key] = undefined; continue; } - setValue(cfg, key, parseScalar(rest)); - i++; + obj[key] = parseScalar(rest); } - return cfg; + return { value: obj, next: i }; } -function setValue(cfg: RepoConfig, key: keyof RepoConfig, value: unknown): void { - if (value === undefined) return; - switch (key) { - case "version": - if (typeof value === "number") cfg.version = value; - break; - case "include": - case "exclude": - if (Array.isArray(value)) cfg[key] = value.map(String); - break; - case "manual_reviewers": - if (Array.isArray(value)) { - cfg.manual_reviewers = value - .map((v) => String(v).replace(/^@/, "").trim()) - .filter(Boolean); - } - break; - case "min_severity": - if (value === "critical" || value === "important" || value === "normal") { - cfg.min_severity = value; - } - break; - case "instructions": - if (typeof value === "string") cfg.instructions = value; - break; - case "block_on_critical": - if (typeof value === "boolean") cfg.block_on_critical = value; - break; - case "auto_review": - if (typeof value === "boolean") cfg.auto_review = value; - break; +/** 把解析出的原始对象按 schema 写进 RepoConfig(未知键忽略) */ +function assignKnown(cfg: RepoConfig, value: Record): void { + for (const [key, v] of Object.entries(value)) { + if (v === undefined) continue; + switch (key) { + case "version": + if (typeof v === "number") cfg.version = v; + break; + case "include": + case "exclude": + if (Array.isArray(v)) cfg[key] = v.map(String); + break; + case "manual_reviewers": + if (Array.isArray(v)) { + cfg.manual_reviewers = v.map((x) => String(x).replace(/^@/, "").trim()).filter(Boolean); + } + break; + case "min_severity": + if (v === "critical" || v === "important" || v === "normal") { + cfg.min_severity = v; + } + break; + case "instructions": + if (typeof v === "string") cfg.instructions = v; + break; + case "block_on_critical": + if (typeof v === "boolean") cfg.block_on_critical = v; + break; + case "auto_review": + if (typeof v === "boolean") cfg.auto_review = v; + break; + case "observability": + assignObservability(cfg, v); + break; + } } } +function assignObservability(cfg: RepoConfig, value: unknown): void { + if (typeof value !== "object" || value === null) return; + const rawLogs = (value as Record).logs; + if (typeof rawLogs !== "object" || rawLogs === null) return; + const logs = rawLogs as Record; + const out: NonNullable["logs"] = {}; + if (typeof logs.enabled === "boolean") out.enabled = logs.enabled; + if (typeof logs.invocation_logs === "boolean") out.invocation_logs = logs.invocation_logs; + if (Object.keys(out).length > 0) cfg.observability = { logs: out }; +} + function parseScalar(raw: string): string | number | boolean { // 若包含带引号的文本,保留内容 if (/^["'].*["']$/.test(raw)) return raw.slice(1, -1); diff --git a/template/heimdall-review.yml b/template/heimdall-review.yml index 8d2ea9d..3a5c9da 100644 --- a/template/heimdall-review.yml +++ b/template/heimdall-review.yml @@ -1,6 +1,6 @@ # 海姆达尔 (Heimdall) — GitHub Actions 模式 # 复制本文件到「被审查的仓库」的 .github/workflows/ 目录, -# 并将 scripts/heimdall-review.js 一并复制到该仓库 scripts/ 目录。 +# 并将 scripts/heimdall-review.js 与 scripts/observability.js 一并复制到该仓库 scripts/ 目录。 name: Heimdall Code Review on: diff --git a/test/observability.test.js b/test/observability.test.js new file mode 100644 index 0000000..61f1b2a --- /dev/null +++ b/test/observability.test.js @@ -0,0 +1,138 @@ +// 可观测性模块测试(针对编译后的 lib/observability.js) +"use strict"; +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { + createObserver, + resolveObserverOptions, + applyLogOverrides, + newReviewId, +} = require("../lib/observability.js"); + +/** 捕获 console.log/error 输出(observer 方法均为同步调用) */ +function captureLogs(fn) { + const logs = []; + const origLog = console.log; + const origError = console.error; + console.log = (line) => logs.push({ level: "log", line }); + console.error = (line) => logs.push({ level: "error", line }); + try { + fn(); + } finally { + console.log = origLog; + console.error = origError; + } + return logs; +} + +/** 执行 fn 并断言只输出一行,返回解析后的 JSON 行 */ +function lineFor(fn) { + const logs = captureLogs(fn); + assert.equal(logs.length, 1, `期望 1 行日志,实际 ${logs.length} 行: ${JSON.stringify(logs)}`); + return JSON.parse(logs[0].line); +} + +test("JSON-lines:单行可解析,含 ts/level/event/mode/msg 与自定义字段", () => { + const line = lineFor(() => createObserver({ mode: "probot" }).info("review.start", "开始审查", { pr: 12 })); + assert.equal(line.event, "review.start"); + assert.equal(line.level, "info"); + assert.equal(line.mode, "probot"); + assert.equal(line.msg, "开始审查"); + assert.equal(line.pr, 12); + assert.ok(typeof line.ts === "string" && !Number.isNaN(Date.parse(line.ts))); +}); + +test("级别过滤:info 时 debug 被过滤,error 恒输出", () => { + const logs = captureLogs(() => { + const obs = createObserver({ mode: "test", level: "info" }); + obs.info("a", "i"); + obs.debug("b", "d"); + obs.warn("c", "w"); + obs.error("d", "e"); + }); + assert.deepEqual(logs.map((l) => JSON.parse(l.line).event), ["a", "c", "d"]); +}); + +test("级别过滤:debug 级别输出 info 与 debug", () => { + const logs = captureLogs(() => { + const obs = createObserver({ mode: "test", level: "debug" }); + obs.debug("a", "d"); + obs.info("b", "i"); + }); + assert.deepEqual(logs.map((l) => JSON.parse(l.line).event), ["a", "b"]); +}); + +test("Span.finish 自动带 durationMs", () => { + const line = lineFor(() => { + const obs = createObserver({ mode: "test" }); + const span = obs.start(); + span.finish("llm.done", { status: "ok" }); + }); + assert.equal(line.event, "llm.done"); + assert.equal(line.status, "ok"); + assert.ok(typeof line.durationMs === "number" && line.durationMs >= 0); +}); + +test("enabled=false:info 被关,error 与 invocation 仍输出", () => { + const logs = captureLogs(() => { + const obs = createObserver({ mode: "test", enabled: false }); + obs.info("a", "i"); + obs.error("b", "e"); + obs.invocation("c", "s"); + }); + assert.deepEqual(logs.map((l) => JSON.parse(l.line).event), ["b", "c"]); +}); + +test("invocationLogs=false:invocation 被关,error 仍输出", () => { + const logs = captureLogs(() => { + const obs = createObserver({ mode: "test", invocationLogs: false }); + obs.invocation("a", "s"); + obs.error("b", "e"); + }); + assert.deepEqual(logs.map((l) => JSON.parse(l.line).event), ["b"]); +}); + +test("child 绑定上下文,父 observer 不受影响", () => { + const logs = captureLogs(() => { + const base = createObserver({ mode: "test" }); + base.info("a", "parent"); + base.child({ repo: "octo/app", pr: 3, reviewId: "h-1" }).info("b", "child"); + }); + const parsed = logs.map((l) => JSON.parse(l.line)); + assert.equal(parsed[0].repo, undefined); + assert.equal(parsed[1].repo, "octo/app"); + assert.equal(parsed[1].pr, 3); + assert.equal(parsed[1].reviewId, "h-1"); +}); + +test("applyLogOverrides:覆盖 enabled,未配置保持原样", () => { + const logs = captureLogs(() => { + const base = createObserver({ mode: "test", enabled: true }); + applyLogOverrides(base, undefined).info("a", "kept"); + applyLogOverrides(base, { enabled: false }).info("b", "off"); + applyLogOverrides(base, { invocation_logs: false }).error("c", "err"); + }); + assert.deepEqual(logs.map((l) => JSON.parse(l.line).event), ["a", "c"]); +}); + +test("resolveObserverOptions:默认值与 env 覆盖", () => { + assert.deepEqual(resolveObserverOptions("worker", {}), { + mode: "worker", + enabled: true, + invocationLogs: true, + level: "info", + }); + const opts = resolveObserverOptions("worker", { + HEIMDALL_LOG_ENABLED: "false", + HEIMDALL_INVOCATION_LOGS: "false", + HEIMDALL_LOG_LEVEL: "debug", + }); + assert.equal(opts.enabled, false); + assert.equal(opts.invocationLogs, false); + assert.equal(opts.level, "debug"); + assert.equal(resolveObserverOptions("worker", { HEIMDALL_LOG_LEVEL: "verbose" }).level, "info"); +}); + +test("newReviewId 前缀", () => { + assert.match(newReviewId(), /^h-[a-z0-9]+$/); +}); diff --git a/test/repo-config.test.js b/test/repo-config.test.js index 7d14757..5cadaf0 100644 --- a/test/repo-config.test.js +++ b/test/repo-config.test.js @@ -109,3 +109,23 @@ manual_reviewers: assert.equal(cfg.min_severity, "important"); assert.deepEqual(cfg.manual_reviewers, ["octocat", "steven"]); }); + +test("parseHeimdallConfig:observability 嵌套块", () => { + const yaml = `observability: + logs: + enabled: false + invocation_logs: true`; + const cfg = parseHeimdallConfig(yaml); + assert.equal(cfg.observability.logs.enabled, false); + assert.equal(cfg.observability.logs.invocation_logs, true); +}); + +test("parseHeimdallConfig:observability 与其它键混排", () => { + const yaml = `auto_review: true +observability: + logs: + enabled: false`; + const cfg = parseHeimdallConfig(yaml); + assert.equal(cfg.auto_review, true); + assert.equal(cfg.observability.logs.enabled, false); +}); diff --git a/worker/index.ts b/worker/index.ts index 21960dc..e9c9818 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,6 @@ import { Buffer } from "node:buffer"; import { createHmac, timingSafeEqual, createPrivateKey, sign } from "node:crypto"; +import { applyLogOverrides, createObserver, newReviewId, resolveObserverOptions } from "../src/observability"; import { formatSafeDiff, LABELS, parseReview, renderMarkdown, ReviewLanguage, ReviewResult, validateIssueLines } from "../src/review/parse"; import { buildSystemPrompt } from "../src/review/prompt"; import { filterByMinSeverity, filterFiles, parseHeimdallConfig, RepoConfig } from "../src/review/repo-config"; @@ -25,6 +26,9 @@ interface Env { AI_MODEL?: string; REVIEW_LANGUAGE?: string; MAX_DIFF_LENGTH?: string; + HEIMDALL_LOG_ENABLED?: string; + HEIMDALL_INVOCATION_LOGS?: string; + HEIMDALL_LOG_LEVEL?: string; } export default { @@ -52,7 +56,18 @@ export default { return new Response("Ignored", { status: 200 }); } const pr = payload.pull_request; - if (pr.draft || pr.user?.type === "Bot") return new Response("Ignored", { status: 200 }); + if (pr.draft || pr.user?.type === "Bot") { + createObserver(resolveObserverOptions("worker", env)).child({ + repo: `${payload.repository.owner.login}/${payload.repository.name}`, + pr: pr.number, + sha: pr.head?.sha, + reviewId: newReviewId(), + trigger: "auto", + }).invocation("review.skip", pr.draft ? "草稿 PR,跳过审查" : "机器人发起的 PR,跳过审查", { + reason: pr.draft ? "draft_pr" : "bot_pr", + }); + return new Response("Ignored", { status: 200 }); + } ctx.waitUntil(runWebhookReview(env, payload, pr.number, undefined, true).catch((err) => console.error("审查失败:", err))); return new Response("OK", { status: 200 }); } @@ -142,17 +157,27 @@ async function runWebhookReview(env: Env, payload: any, pullNumber: number, trig body: options.body ? JSON.stringify(options.body) : undefined, }); + let obs = createObserver(resolveObserverOptions("worker", env)).child({ + repo: `${owner}/${repo}`, + pr: pullNumber, + reviewId: newReviewId(), + trigger: triggerAuthor ? "manual" : "auto", + }); + const reviewSpan = obs.start(); + obs.info("review.start", "开始审查"); + // 1. 读取配置与完整文件列表 const [repoConfig, files] = await Promise.all([ loadRepoConfig(gh, owner, repo), fetchAllFiles(gh, owner, repo, pullNumber), ]); + obs = applyLogOverrides(obs, repoConfig.observability?.logs); if (isAuto && repoConfig.auto_review !== true) { - console.log("海姆达尔:默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)"); + obs.invocation("review.skip", "默认仅按需审查,跳过自动审查(可在 PR 评论发 @CoderHeimdall 手动触发;配置 auto_review: true 开启自动)", { reason: "not_auto_review" }); return; } if (triggerAuthor && !isAllowedManualReviewer(repoConfig.manual_reviewers, triggerAuthor)) { - console.log(`海姆达尔:@${triggerAuthor} 不在 manual_reviewers 白名单,忽略触发`); + obs.invocation("review.skip", `@${triggerAuthor} 不在 manual_reviewers 白名单,忽略触发`, { reason: "reviewer_not_whitelisted", author: triggerAuthor }); return; } @@ -165,15 +190,16 @@ async function runWebhookReview(env: Env, payload: any, pullNumber: number, trig headSha = prData.head?.sha; } } + if (headSha) obs = obs.child({ sha: headSha }); if (headSha && (await hasExistingReview(gh, owner, repo, pullNumber, headSha))) { - console.log(`海姆达尔:commit ${headSha.slice(0, 8)} 已审查过,跳过重复审查`); + obs.invocation("review.skip", "该 commit 已审查过,跳过重复审查", { reason: "dup_review" }); return; } // 跨触发即时去重:模块级缓存(并发竞态缓解,无需额外权限) const cacheKey = `${owner}/${repo}#${pullNumber}`; const cached = recentReviews.get(cacheKey); if (headSha && cached && cached.sha === headSha && Date.now() - cached.time < REVIEW_CACHE_MS) { - console.log(`海姆达尔:commit ${headSha.slice(0, 8)} 短时间内已审查,跳过`); + obs.invocation("review.skip", "该 commit 短时间内已审查,跳过", { reason: "dup_cache" }); return; } // 跨触发即时去重:已有 heimdall/reviewed 成功状态则跳过(防自动+手动竞态重复) @@ -182,7 +208,7 @@ async function runWebhookReview(env: Env, payload: any, pullNumber: number, trig if (stRes.ok) { const st = (await stRes.json()) as { statuses?: Array<{ context?: string; state?: string }> }; if (st.statuses?.some((s) => s.context === "heimdall/reviewed" && s.state === "success")) { - console.log(`海姆达尔:commit ${headSha.slice(0, 8)} 已有审查标记,跳过`); + obs.invocation("review.skip", "该 commit 已有审查标记,跳过", { reason: "dup_status" }); return; } } @@ -190,12 +216,14 @@ async function runWebhookReview(env: Env, payload: any, pullNumber: number, trig const reviewable = filterFiles(files, repoConfig); const stats = diffStats(reviewable); const diff = formatSafeDiff(reviewable, Number(env.MAX_DIFF_LENGTH ?? 40000)); + obs.debug("review.diff", "读取变更", { files: stats.files, additions: stats.additions, deletions: stats.deletions, diffBytes: diff.length }); -const language: ReviewLanguage = (env.REVIEW_LANGUAGE ?? "en").toLowerCase() as ReviewLanguage; -const L = LABELS[language] ?? LABELS.en; + const language: ReviewLanguage = (env.REVIEW_LANGUAGE ?? "en").toLowerCase() as ReviewLanguage; + const L = LABELS[language] ?? LABELS.en; if (!diff.trim()) { await postReview(gh, owner, repo, pullNumber, renderReport(stats, "", undefined, language, L.noChange)); + obs.invocation("review.invocation", "无可审查变更", { outcome: "empty", durationMs: reviewSpan.elapsed() }); return; } @@ -204,15 +232,21 @@ const L = LABELS[language] ?? LABELS.en; : buildSystemPrompt(language); // 2. 调用 LLM 生成审查报告 + let outcome = "posted"; let report: string; let criticalCount = 0; + const issueCounts = { critical: 0, important: 0, normal: 0 }; let inlineComments: Array<{ path: string; line: number; side: string; body: string }> = []; + const llmSpan = obs.start(); try { const raw = await generateReview(env, diff, systemPrompt); + llmSpan.finish("llm.done", { provider: providerName(env), model: env.AI_MODEL, status: "ok" }); const parsed = parseReview(raw); const filtered = parsed ? filterByMinSeverity(parsed, repoConfig.min_severity) : null; criticalCount = filtered ? filtered.issues.filter((i) => i.severity === "critical").length : 0; if (filtered) { + for (const i of filtered.issues) issueCounts[i.severity]++; + obs.info("review.parse", "审查结果解析成功", { status: "ok", issues: filtered.issues.length }); filtered.issues = validateIssueLines(filtered.issues, reviewable); report = renderReport(stats, renderMarkdown(filtered, language), filtered, language); inlineComments = filtered.issues @@ -232,10 +266,15 @@ const L = LABELS[language] ?? LABELS.en; ].join(""), })); } else { + outcome = "parse_fallback"; + obs.warn("review.parse", "结构化解析失败,降级为整体报告", { status: "fallback" }); report = renderReport(stats, raw, undefined, language); } } catch (err) { - report = renderReport(stats, `⚠️ ${L.reviewFailed}:${err instanceof Error ? err.message : String(err)}`, undefined, language); + outcome = "failed"; + const message = err instanceof Error ? err.message : String(err); + obs.error("review.error", `LLM 调用失败:${message}`, { reason: "llm_error", provider: providerName(env), model: env.AI_MODEL, durationMs: llmSpan.elapsed() }); + report = renderReport(stats, `⚠️ ${L.reviewFailed}:${message}`, undefined, language); } // block_on_critical:存在 critical 时设置状态阻断合并,无则置成功 @@ -253,6 +292,12 @@ const L = LABELS[language] ?? LABELS.en; // 3. 以 Review 形式回写 PR(整体报告 + 行内评论) await postReview(gh, owner, repo, pullNumber, report, inlineComments); + obs.info("review.post", "审查已发布", issueCounts); + obs.invocation("review.invocation", outcome === "failed" ? "审查失败" : "审查完成", { + outcome, + durationMs: reviewSpan.elapsed(), + ...issueCounts, + }); // 4. 标记该 commit 已审查(供跨触发去重,防自动+手动竞态重复) if (headSha) { @@ -263,11 +308,15 @@ const L = LABELS[language] ?? LABELS.en; body: { state: "success", context: "heimdall/reviewed", description: "已完成海姆达尔审查" }, }); } catch (err) { - console.error("设置已审查状态失败(不影响审查):", err instanceof Error ? err.message : err); + obs.warn("review.status", "设置已审查状态失败(不影响审查)", { reason: "status_failed", detail: err instanceof Error ? err.message : String(err) }); } } } +function providerName(env: Env): string { + return (env.AI_PROVIDER ?? "anthropic").toLowerCase(); +} + function severityLabel(severity: string): string { switch (severity) { case "critical":