Unverified 提交 8b335617 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

Add Codex PR review workflow (#3303)

## Summary - Add a Codex PR Review workflow modeled after Keppo's trusted context and validated findings pipeline. - Add Codex auth/run helpers, PR review context builders, validators, and inline-comment posting scripts. - Document the new workflow in the GitHub workflows overview. ## Test plan - node --check for new PR review scripts - bash -n for new Codex shell helpers - Ruby YAML parse for .github/workflows/codex-pr-review.yml - npm run fmt && npm run lint:fix && npm run ts - npm test <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3303" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 b48d35ac
You are reviewing a GitHub pull request using the workflow-generated context in `{{CONTEXT_PATH}}`.
Focus on bugs, regressions, security issues, and missing validation. Be concise and specific. Prefer findings that are actionable and grounded in the provided diff.
Dyad-specific review focus:
- Electron IPC changes must preserve the secure main/renderer boundary, validate inputs, and avoid exposing broad filesystem or process access.
- Expected user, auth, validation, missing-entity, and refusal errors from main/IPC code should use `DyadError` and an appropriate `DyadErrorKind` so they are not reported as product exceptions.
- IPC-backed renderer data fetching should generally use TanStack Query patterns already present in the repo.
- UI primitive changes should use Base UI wrappers rather than Radix UI, and user-facing flows should include appropriate loading, empty, disabled, and error states.
- Database schema changes should include the required Drizzle migration and avoid dead schema/infrastructure that is not used by the PR.
- Local agent tool changes should correctly mark state-modifying tools and preserve read-only/plan-only guards.
- E2E or test changes should respect Dyad's Playwright/Electron constraints and avoid relying on stale build output.
Mandatory output:
- You MUST write `{{OUTPUT_MD_PATH}}` before finishing.
- That file must contain the markdown review summary directly (NOT wrapped in JSON).
- You MUST also write `{{OUTPUT_FINDINGS_PATH}}` before finishing.
- That file must contain JSON with this exact top-level shape: `{"findings":[...]}`.
Formatting requirements:
Start with a verdict line and a recommendation line in this exact format:
`**Verdict: [EMOJI + TEXT]**`
`**Recommendation: [auto-fix | human-review | ready]**`
Use one of these verdicts:
- `:white_check_mark: YES - Ready to merge` - no HIGH issues. MEDIUM issues do NOT block merge.
- `:thinking: NOT SURE - Potential issues` - has MEDIUM issues worth noting, but none are merge blockers on their own
- `:no_entry: NO - Do NOT merge` - has HIGH severity issues that MUST be fixed before merge
**Only HIGH severity issues block merge.** MEDIUM issues are informational.
Use one of these recommendations:
- `auto-fix` - all HIGH issues are mechanical/deterministic (code bugs, missing validation, broken tests, type errors). An agent can fix them.
- `human-review` - at least one HIGH issue requires human judgment (architectural decisions, product direction, security policy, ambiguous requirements)
- `ready` - no HIGH issues found, PR is ready to merge
If you found HIGH or MEDIUM issues, add an issues table:
```
### Issues Summary
| Severity | File | Issue |
| ---------------------- | --------------------- | ---------------------- |
| :red_circle: HIGH | `path/to/file.ts:45` | Short issue title |
| :yellow_circle: MEDIUM | `path/to/other.ts:12` | Short issue title |
```
Use severity emojis: `:red_circle: HIGH`, `:yellow_circle: MEDIUM`.
Cite the exact single `path:line` location from the diff.
For every actionable issue, the table's `Issue` text must exactly match that finding's `title` in `{{OUTPUT_FINDINGS_PATH}}`.
Inline findings JSON requirements:
- Include only HIGH and MEDIUM findings in `{{OUTPUT_FINDINGS_PATH}}`.
- Use this exact shape:
```json
{
"findings": [
{
"severity": "HIGH",
"path": "path/to/file.ts",
"line": 45,
"title": "Short issue title",
"body": "One concise paragraph explaining the issue and why it matters.",
"suggestion": "Optional concise fix suggestion"
}
]
}
```
- `path` must match a changed file from the context.
- `line` must be a single new-side line that appears inside that file's `commentableLineRanges` in the context.
- Keep `title`, `body`, and optional `suggestion` plain text, concise, and reviewer-facing.
- If there are no HIGH or MEDIUM findings, write `{"findings":[]}`.
If you found LOW severity issues, put them in a collapsible section after the table:
```
<details>
<summary>:green_circle: Low Priority Notes (X items)</summary>
- **Brief title** - Description. (`path/to/file.ts`)
</details>
```
If no issues were found, use this format instead of the table:
`:white_check_mark: No significant issues found.`
End every summary with:
```
---
_Generated by Codex_
```
Do not include a heading - the workflow adds one.
Do not mention hidden reasoning, private policies, or workflow internals.
Constraints:
1. Treat `{{CONTEXT_PATH}}` as the authoritative PR state for this run.
2. Do not attempt to write to GitHub or mutate repository state.
3. If the diff is truncated, say so in the summary when it affects confidence.
4. If you are uncertain, say so plainly instead of inventing issues.
5. Keep the markdown summary and findings JSON consistent with each other.
name: Codex PR Review
on:
pull_request_target:
types: [opened, synchronize, ready_for_review, reopened, closed]
# Restrict default permissions; each job declares only what it needs.
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
codex-review:
environment: ai-bots
# Only review code from regular contributors since Codex review has non-trivial costs.
# This also keeps privileged pull_request_target tokens away from untrusted PRs.
if: >-
github.event.action != 'closed' &&
contains(
fromJSON('["wwwillchen","keppo-bot","keppo-bot[bot]","dyad-assistant","azizmejri1","princeaden1","nourzakhama2003","ryangroch"]'),
github.event.pull_request.user.login
)
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
pull-requests: write
env:
REVIEW_CONTEXT_PATH: tmp/pr-review/codex-context.json
REVIEW_PROMPT_PATH: tmp/pr-review/codex-prompt.txt
REVIEW_OUTPUT_PATH: tmp/pr-review/codex-review.md
REVIEW_FINDINGS_PATH: tmp/pr-review/codex-findings.json
CODEX_HOME: tmp/pr-review/codex-home
steps:
- name: Create read-only agent token
id: agent-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.DYAD_GITHUB_APP_ID }}
private-key: ${{ secrets.DYAD_GITHUB_APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: read
- name: Checkout trusted workflow repo
uses: actions/checkout@v5
with:
repository: ${{ github.repository }}
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Build PR review context
id: context
env:
GITHUB_TOKEN: ${{ steps.agent-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
OUTPUT_PATH: ${{ env.REVIEW_CONTEXT_PATH }}
run: node scripts/pr-review/build-context.mjs
- name: Install Codex CLI
run: npm install -g @openai/codex@latest
- name: Write Codex auth file
env:
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
CODEX_AUTH_JSON_1: ${{ secrets.CODEX_AUTH_JSON_1 }}
CODEX_AUTH_JSON_2: ${{ secrets.CODEX_AUTH_JSON_2 }}
run: bash scripts/codex-commit-review/write-codex-auth.sh
- name: Render Codex review prompt
env:
TEMPLATE_PATH: .github/prompts/codex-pr-review.txt
OUTPUT_PATH: ${{ env.REVIEW_PROMPT_PATH }}
CONTEXT_PATH: ${{ env.REVIEW_CONTEXT_PATH }}
OUTPUT_MD_PATH: ${{ env.REVIEW_OUTPUT_PATH }}
OUTPUT_FINDINGS_PATH: ${{ env.REVIEW_FINDINGS_PATH }}
run: node scripts/issue-agent/render-template.mjs
- name: Run Codex PR review
env:
PROMPT_PATH: ${{ env.REVIEW_PROMPT_PATH }}
run: bash scripts/codex-commit-review/run-codex.sh
- name: Refresh trusted post-agent helpers
uses: actions/checkout@v5
with:
repository: ${{ github.repository }}
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
path: tmp/pr-review/trusted-post-agent
- name: Validate Codex review summary
env:
CONTEXT_PATH: ${{ env.REVIEW_CONTEXT_PATH }}
REVIEW_PATH: ${{ env.REVIEW_OUTPUT_PATH }}
EXPECTED_CONTEXT_SHA: ${{ steps.context.outputs.context_sha }}
run: node tmp/pr-review/trusted-post-agent/scripts/pr-review/validate-review-summary.mjs
- name: Validate Codex findings
id: validate-findings
continue-on-error: true
env:
CONTEXT_PATH: ${{ env.REVIEW_CONTEXT_PATH }}
REVIEW_PATH: ${{ env.REVIEW_OUTPUT_PATH }}
FINDINGS_PATH: ${{ env.REVIEW_FINDINGS_PATH }}
EXPECTED_CONTEXT_SHA: ${{ steps.context.outputs.context_sha }}
run: node tmp/pr-review/trusted-post-agent/scripts/pr-review/validate-review.mjs
- name: Warn when Codex findings validation fails
if: ${{ steps.validate-findings.outcome == 'failure' }}
run: |
echo "::warning::Codex findings validation failed; skipped inline comments."
echo "Codex findings validation failed; skipped inline comments." >> "$GITHUB_STEP_SUMMARY"
- name: Create fresh post-review token
id: post-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.DYAD_GITHUB_APP_ID }}
private-key: ${{ secrets.DYAD_GITHUB_APP_PRIVATE_KEY }}
permission-pull-requests: write
permission-issues: write
- name: Post Codex inline review comments
id: post-inline
if: ${{ steps.validate-findings.outcome == 'success' }}
continue-on-error: true
env:
GITHUB_TOKEN: ${{ steps.post-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
CONTEXT_PATH: ${{ env.REVIEW_CONTEXT_PATH }}
FINDINGS_PATH: ${{ env.REVIEW_FINDINGS_PATH }}
run: node tmp/pr-review/trusted-post-agent/scripts/pr-review/post-inline-review.mjs
- name: Warn when Codex inline posting fails
if: ${{ steps.post-inline.outcome == 'failure' }}
run: |
echo "::warning::Codex inline comment posting failed; summary comment still posted."
echo "Codex inline comment posting failed; summary comment still posted." >> "$GITHUB_STEP_SUMMARY"
- name: Post Codex review comment
if: ${{ always() && steps.post-token.outcome == 'success' }}
uses: actions/github-script@v7
env:
REVIEW_PATH: ${{ env.REVIEW_OUTPUT_PATH }}
with:
github-token: ${{ steps.post-token.outputs.token }}
script: |
const fs = require('node:fs');
const reviewPath = process.env.REVIEW_PATH;
if (!reviewPath) {
throw new Error('REVIEW_PATH is required');
}
const summary = fs.readFileSync(reviewPath, 'utf8').trim();
if (!summary) {
throw new Error('Validated Codex review is missing summary text');
}
const owner = context.repo.owner;
const repo = context.repo.repo;
const issue_number = context.payload.pull_request.number;
const body = [
'<!-- pr-review:codex -->',
'## :mag: Code Review Summary (Codex)',
'',
summary,
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
core.info('Created Codex review comment');
#!/usr/bin/env bash
set -euo pipefail
test -n "${PROMPT_PATH:-}"
prompt="$(cat "${PROMPT_PATH}")"
log_file="$(mktemp "${RUNNER_TEMP:-/tmp}/codex-commit-review.XXXXXX.log")"
cleanup() {
rm -f "${log_file}"
}
trap cleanup EXIT
echo "Codex output suppressed. Session logs are uploaded separately."
if codex exec --dangerously-bypass-approvals-and-sandbox \
"$prompt" >"${log_file}" 2>&1; then
echo "Codex completed successfully."
exit 0
else
status=$?
fi
echo "Codex exited with code ${status}. Full output follows."
cat "${log_file}"
exit "${status}"
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
bash "${script_dir}/../codex/write-auth.sh"
#!/usr/bin/env bash
set -euo pipefail
codex_home="${CODEX_HOME:-${HOME}/.codex}"
declare -a candidates=()
declare -A seen=()
add_candidate() {
local value="${1:-}"
if [ -z "${value}" ] || [ -n "${seen["${value}"]+x}" ]; then
return
fi
candidates+=("${value}")
seen["${value}"]=1
}
add_candidate "${CODEX_AUTH_JSON:-}"
add_candidate "${CODEX_AUTH_JSON_1:-}"
add_candidate "${CODEX_AUTH_JSON_2:-}"
if [ "${#candidates[@]}" -eq 0 ]; then
echo "Expected CODEX_AUTH_JSON or CODEX_AUTH_JSON_1/CODEX_AUTH_JSON_2 to be set" >&2
exit 1
fi
selected_index=0
if [ "${#candidates[@]}" -gt 1 ]; then
random_number="$(od -An -N4 -tu4 /dev/urandom | tr -d '[:space:]')"
selected_index=$((random_number % ${#candidates[@]}))
fi
mkdir -p "${codex_home}"
(umask 077 && printf '%s' "${candidates[${selected_index}]}" > "${codex_home}/auth.json")
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const templatePath = process.env.TEMPLATE_PATH;
const outputPath = process.env.OUTPUT_PATH;
const outputName = process.env.OUTPUT_NAME || "prompt";
const githubOutputPath = process.env.GITHUB_OUTPUT;
if (!templatePath) {
throw new Error("TEMPLATE_PATH is required");
}
const template = fs.readFileSync(templatePath, "utf8");
const rendered = template.replace(/{{([A-Z0-9_]+)}}/g, (_match, name) => {
const value = process.env[name];
if (typeof value !== "string") {
throw new Error(`Missing template variable: ${name}`);
}
return value;
});
if (outputPath) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, rendered);
}
if (githubOutputPath) {
const delimiter = `EOF_${crypto.randomUUID()}`;
fs.appendFileSync(
githubOutputPath,
`${outputName}<<${delimiter}\n${rendered}\n${delimiter}\n`,
);
}
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const token = process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY;
const prNumber = Number.parseInt(process.env.PR_NUMBER ?? "", 10);
const outputPath = process.env.OUTPUT_PATH;
const githubOutputPath = process.env.GITHUB_OUTPUT;
if (!token) throw new Error("GITHUB_TOKEN is required");
if (!repository) throw new Error("GITHUB_REPOSITORY is required");
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error("PR_NUMBER must be a positive integer");
}
if (!outputPath) throw new Error("OUTPUT_PATH is required");
if (!githubOutputPath) throw new Error("GITHUB_OUTPUT is required");
const [owner, repo] = repository.split("/");
if (!owner || !repo)
throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"User-Agent": "dyad-pr-review",
"X-GitHub-Api-Version": "2022-11-28",
};
const api = async (pathname, accept = headers.Accept) => {
const response = await fetch(`https://api.github.com/${pathname}`, {
headers: {
...headers,
Accept: accept,
},
});
if (!response.ok) {
throw new Error(
`GitHub API ${pathname} failed: ${response.status} ${response.statusText}`,
);
}
return response;
};
const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
function appendRange(ranges, start, end) {
if (end < start) return;
const previous = ranges.at(-1);
if (previous && start <= previous.end + 1) {
previous.end = Math.max(previous.end, end);
return;
}
ranges.push({ start, end });
}
function getCommentableLineRanges(patch) {
if (!patch) return [];
const ranges = [];
let rightLine = null;
let activeRangeStart = null;
let activeRangeEnd = null;
const flushActiveRange = () => {
if (activeRangeStart !== null && activeRangeEnd !== null) {
appendRange(ranges, activeRangeStart, activeRangeEnd);
}
activeRangeStart = null;
activeRangeEnd = null;
};
for (const line of patch.split("\n")) {
const hunkHeader = line.match(HUNK_HEADER_RE);
if (hunkHeader) {
flushActiveRange();
rightLine = Number.parseInt(hunkHeader[1], 10);
continue;
}
if (rightLine === null || !line) {
continue;
}
const prefix = line[0];
if (prefix === "+" || prefix === " ") {
if (activeRangeStart === null) {
activeRangeStart = rightLine;
}
activeRangeEnd = rightLine;
rightLine += 1;
continue;
}
if (prefix === "-") {
flushActiveRange();
continue;
}
if (prefix === "\\") {
continue;
}
flushActiveRange();
rightLine = null;
}
flushActiveRange();
return ranges;
}
const pullRequestResponse = await api(
`repos/${owner}/${repo}/pulls/${prNumber}`,
);
const pullRequest = await pullRequestResponse.json();
const files = [];
for (let page = 1; page <= 10; page += 1) {
const response = await api(
`repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=100&page=${page}`,
);
const pageFiles = await response.json();
files.push(...pageFiles);
if (pageFiles.length < 100) break;
}
let diff = "";
let diffTruncated = false;
try {
const diffResponse = await api(
`repos/${owner}/${repo}/pulls/${prNumber}`,
"application/vnd.github.v3.diff",
);
diff = await diffResponse.text();
const maxDiffBytes = 180 * 1024;
diffTruncated = diff.length > maxDiffBytes;
if (diffTruncated) {
diff = diff.slice(0, maxDiffBytes);
}
} catch {
// GitHub returns 406 when the diff is too large to generate.
// Fall back to per-file patches already collected above.
diffTruncated = true;
}
const maxPatchChars = 48000;
const normalizedFiles = files.map((file) => {
const fullPatch = typeof file.patch === "string" ? file.patch : "";
return {
path: file.filename,
status: file.status,
additions: file.additions,
deletions: file.deletions,
changes: file.changes,
patch: fullPatch.slice(0, maxPatchChars),
patchTruncated: fullPatch.length > maxPatchChars,
commentableLineRanges: getCommentableLineRanges(fullPatch),
};
});
const payload = {
generatedAt: new Date().toISOString(),
repository,
pullRequest: {
number: pullRequest.number,
title: pullRequest.title,
body: pullRequest.body ?? "",
url: pullRequest.html_url,
author: pullRequest.user?.login ?? "",
baseRef: pullRequest.base?.ref ?? "",
headRef: pullRequest.head?.ref ?? "",
headSha: pullRequest.head?.sha ?? "",
changedFiles: pullRequest.changed_files ?? normalizedFiles.length,
additions: pullRequest.additions ?? 0,
deletions: pullRequest.deletions ?? 0,
},
files: normalizedFiles,
diff,
diffTruncated,
};
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
const serialized = JSON.stringify(payload, null, 2);
fs.writeFileSync(outputPath, serialized);
const contextSha = crypto.createHash("sha256").update(serialized).digest("hex");
fs.appendFileSync(githubOutputPath, `context_sha=${contextSha}\n`);
import fs from "node:fs";
const token = process.env.GITHUB_TOKEN;
const repository = process.env.GITHUB_REPOSITORY;
const prNumber = Number.parseInt(process.env.PR_NUMBER ?? "", 10);
const contextPath = process.env.CONTEXT_PATH;
const findingsPath = process.env.FINDINGS_PATH;
if (!token) throw new Error("GITHUB_TOKEN is required");
if (!repository) throw new Error("GITHUB_REPOSITORY is required");
if (!Number.isInteger(prNumber) || prNumber <= 0) {
throw new Error("PR_NUMBER must be a positive integer");
}
if (!contextPath) throw new Error("CONTEXT_PATH is required");
if (!findingsPath) throw new Error("FINDINGS_PATH is required");
const [owner, repo] = repository.split("/");
if (!owner || !repo)
throw new Error(`Invalid GITHUB_REPOSITORY: ${repository}`);
const context = JSON.parse(fs.readFileSync(contextPath, "utf8"));
const findingsPayload = JSON.parse(fs.readFileSync(findingsPath, "utf8"));
const findings = Array.isArray(findingsPayload.findings)
? findingsPayload.findings
: [];
const headSha = context.pullRequest?.headSha;
if (!headSha) {
throw new Error("PR review context is missing pullRequest.headSha");
}
if (findings.length === 0) {
console.log("No Codex inline findings to post.");
process.exit(0);
}
const severityEmoji = {
HIGH: ":red_circle:",
MEDIUM: ":yellow_circle:",
};
const sanitizeDetail = (detail) =>
detail
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
.replace(/gh[spou]_[A-Za-z0-9_]+/g, "[REDACTED_TOKEN]")
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, "[REDACTED_TOKEN]")
.replace(/\s+/g, " ")
.trim()
.slice(0, 600);
const comments = findings.map((finding) => {
const lines = [
`**${severityEmoji[finding.severity]} ${finding.severity}**`,
"",
`**${finding.title}**`,
"",
finding.body.trim(),
];
if (finding.suggestion) {
lines.push("", `:bulb: **Suggestion:** ${finding.suggestion.trim()}`);
}
return {
path: finding.path,
line: finding.line,
side: "RIGHT",
body: lines.join("\n"),
};
});
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "dyad-pr-review",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({
commit_id: headSha,
event: "COMMENT",
body: `Codex review: ${findings.length} inline finding(s).`,
comments,
}),
},
);
if (!response.ok) {
const detail = sanitizeDetail(await response.text());
if (detail) {
console.warn(`::warning::Codex inline review post failed: ${detail}`);
}
throw new Error(
`Failed to create Codex review comments: ${response.status} ${response.statusText}${detail ? ` - ${detail}` : ""}`,
);
}
console.log(`Posted ${comments.length} Codex inline review comment(s).`);
import crypto from "node:crypto";
import fs from "node:fs";
const contextPath = process.env.CONTEXT_PATH;
const reviewPath = process.env.REVIEW_PATH;
const expectedContextSha = process.env.EXPECTED_CONTEXT_SHA;
if (!contextPath) throw new Error("CONTEXT_PATH is required");
if (!reviewPath) throw new Error("REVIEW_PATH is required");
if (!expectedContextSha) throw new Error("EXPECTED_CONTEXT_SHA is required");
const contextRaw = fs.readFileSync(contextPath, "utf8");
const actualContextSha = crypto
.createHash("sha256")
.update(contextRaw)
.digest("hex");
if (actualContextSha !== expectedContextSha) {
throw new Error("PR review context changed after generation");
}
const summary = fs.readFileSync(reviewPath, "utf8").trim();
if (!summary) {
throw new Error("Review output file is empty");
}
// Gracefully handle older summaries that predate the Recommendation line.
const recMatch = summary.match(
/\*\*Recommendation:\s*(auto-fix|human-review|ready)\s*\*\*/,
);
let finalSummary = summary;
if (!recMatch) {
console.warn(
"WARNING: Review summary missing **Recommendation:** line; defaulting to human-review",
);
const verdictIndex = finalSummary.indexOf("**Verdict:");
if (verdictIndex !== -1) {
const lineEnd = finalSummary.indexOf("\n", verdictIndex);
if (lineEnd !== -1) {
finalSummary =
finalSummary.slice(0, lineEnd + 1) +
"**Recommendation: human-review (default)**\n" +
finalSummary.slice(lineEnd + 1);
} else {
finalSummary += "\n**Recommendation: human-review (default)**";
}
} else {
finalSummary = "**Recommendation: human-review**\n\n" + finalSummary;
}
}
fs.writeFileSync(reviewPath, finalSummary);
import crypto from "node:crypto";
import fs from "node:fs";
const contextPath = process.env.CONTEXT_PATH;
const findingsPath = process.env.FINDINGS_PATH;
const reviewPath = process.env.REVIEW_PATH;
const expectedContextSha = process.env.EXPECTED_CONTEXT_SHA;
if (!contextPath) throw new Error("CONTEXT_PATH is required");
if (!findingsPath) throw new Error("FINDINGS_PATH is required");
if (!reviewPath) throw new Error("REVIEW_PATH is required");
if (!expectedContextSha) throw new Error("EXPECTED_CONTEXT_SHA is required");
const contextRaw = fs.readFileSync(contextPath, "utf8");
const context = JSON.parse(contextRaw);
const actualContextSha = crypto
.createHash("sha256")
.update(contextRaw)
.digest("hex");
if (actualContextSha !== expectedContextSha) {
throw new Error("PR review context changed after generation");
}
const summary = fs.readFileSync(reviewPath, "utf8").trim();
const recMatch = summary.match(
/\*\*Recommendation:\s*(auto-fix|human-review|ready)\s*\*\*/,
);
if (!summary) {
throw new Error("Review output file is empty");
}
if (!fs.existsSync(findingsPath)) {
throw new Error("Findings output file is missing");
}
const filesByPath = new Map(
(context.files ?? []).map((file) => [file.path, file]),
);
const lineInRanges = (line, ranges) =>
Array.isArray(ranges) &&
ranges.some(
(range) =>
Number.isInteger(range.start) &&
Number.isInteger(range.end) &&
line >= range.start &&
line <= range.end,
);
const warning = (message) => {
console.warn(`::warning::${message}`);
};
const parseSummaryIssues = (value) => {
const issuesHeader = value.match(
/(^|\n)### Issues Summary\s*\n\n([\s\S]*?)(?:\n<details>|\n---|\n:white_check_mark:|$)/,
);
if (!issuesHeader) {
return [];
}
const lines = issuesHeader[2]
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const rows = [];
for (const line of lines) {
if (!line.startsWith("|")) continue;
if (line.includes("---")) continue;
const cells = line
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
if (cells.length !== 3) continue;
const severity = cells[0]
.replace(/^:[^:]+:\s*/, "")
.trim()
.toUpperCase();
const locationMatch = cells[1].match(/^`([^`:]+(?:\/[^`:]+)*):(\d+)`$/);
if (!locationMatch) continue;
rows.push({
severity,
path: locationMatch[1],
line: Number(locationMatch[2]),
title: cells[2],
});
}
return rows;
};
const findingsRaw = fs.readFileSync(findingsPath, "utf8").trim();
if (!findingsRaw) {
throw new Error("Findings output file is empty");
}
const findingsPayload = JSON.parse(findingsRaw);
if (!findingsPayload || typeof findingsPayload !== "object") {
throw new Error("Findings output must be a JSON object");
}
if (!Array.isArray(findingsPayload.findings)) {
throw new Error("Findings output must include a findings array");
}
const rawFindings = findingsPayload.findings;
const normalizedFindings = [];
const seenKeys = new Set();
for (const [index, finding] of rawFindings.entries()) {
if (!finding || typeof finding !== "object") {
warning(`Skipping finding ${index}: entry must be an object`);
continue;
}
const severity = `${finding.severity ?? ""}`.trim().toUpperCase();
if (severity !== "HIGH" && severity !== "MEDIUM") {
warning(
`Skipping finding ${index}: invalid severity "${finding.severity}"`,
);
continue;
}
const path = `${finding.path ?? ""}`.trim();
const file = filesByPath.get(path);
if (!file) {
warning(`Skipping finding ${index}: unknown changed file "${path}"`);
continue;
}
const lineRaw = `${finding.line ?? ""}`.trim();
const line = /^\d+$/.test(lineRaw) ? Number(lineRaw) : Number.NaN;
if (!Number.isInteger(line) || line <= 0) {
warning(`Skipping finding ${index}: invalid line "${finding.line}"`);
continue;
}
if (!lineInRanges(line, file.commentableLineRanges)) {
warning(
`Skipping finding ${index}: non-commentable line ${line} in ${path}`,
);
continue;
}
const title = `${finding.title ?? ""}`.trim();
const body = `${finding.body ?? ""}`.trim();
const suggestion =
typeof finding.suggestion === "string" ? finding.suggestion.trim() : "";
if (!title) {
warning(`Skipping finding ${index}: missing title`);
continue;
}
if (!body) {
warning(`Skipping finding ${index}: missing body`);
continue;
}
const dedupeKey = `${severity}:${path}:${line}:${title}`;
if (seenKeys.has(dedupeKey)) {
warning(`Skipping finding ${index}: duplicate ${dedupeKey}`);
continue;
}
seenKeys.add(dedupeKey);
normalizedFindings.push({
severity,
path,
line,
title,
body,
...(suggestion ? { suggestion } : {}),
});
}
const hasExplicitRecommendation = Boolean(recMatch);
const recommendation = recMatch?.[1] ?? "human-review";
const highFindings = normalizedFindings.filter(
(finding) => finding.severity === "HIGH",
);
if (recommendation === "ready" && highFindings.length > 0) {
throw new Error("Review summary says ready but findings include HIGH issues");
}
if (
hasExplicitRecommendation &&
(recommendation === "auto-fix" || recommendation === "human-review") &&
highFindings.length === 0
) {
throw new Error(
`Review summary says ${recommendation} but findings do not include HIGH issues`,
);
}
if (
normalizedFindings.length > 0 &&
summary.includes(":white_check_mark: No significant issues found.")
) {
throw new Error(
"Review summary says no significant issues found but findings were emitted",
);
}
const summaryIssues = parseSummaryIssues(summary);
if (normalizedFindings.length > 0 && summaryIssues.length === 0) {
throw new Error(
"Review summary emitted actionable findings but is missing an Issues Summary table",
);
}
if (summaryIssues.length > 0) {
const summaryKeys = new Set(
summaryIssues.map(
(issue) => `${issue.severity}:${issue.path}:${issue.line}:${issue.title}`,
),
);
const findingKeys = new Set(
normalizedFindings.map(
(finding) =>
`${finding.severity}:${finding.path}:${finding.line}:${finding.title}`,
),
);
for (const finding of normalizedFindings) {
const key = `${finding.severity}:${finding.path}:${finding.line}:${finding.title}`;
if (!summaryKeys.has(key)) {
throw new Error(
`Review summary is missing Issues Summary row for finding ${key}`,
);
}
}
for (const issue of summaryIssues) {
const key = `${issue.severity}:${issue.path}:${issue.line}:${issue.title}`;
if (!findingKeys.has(key)) {
throw new Error(
`Findings JSON is missing entry for Issues Summary row ${key}`,
);
}
}
}
fs.writeFileSync(
findingsPath,
JSON.stringify({ findings: normalizedFindings }, null, 2) + "\n",
);
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论