Files
millionco__react-doctor/action.yml
T

379 lines
16 KiB
YAML

name: "React Doctor"
description: "Scan React codebases for security, performance, and correctness issues"
branding:
icon: "activity"
color: "blue"
inputs:
directory:
description: "Project directory to scan"
default: "."
project:
description: 'Workspace project(s) to scan (comma-separated). Use "*" to scan every discovered project.'
default: "*"
blocking:
description: "Severity that fails the workflow: error (default), warning, or none (advisory — report findings but always exit 0)."
default: "error"
comment:
description: "Create or update a sticky pull request summary comment."
default: "true"
review-comments:
description: "Post inline review comments on the changed lines that triggered diagnostics (pull requests only)."
default: "true"
node-version:
description: "Node.js version to use"
default: "24"
version:
description: "react-doctor npm version or package spec to run"
default: "latest"
outputs:
score:
description: "Health score (0-100). Empty when scoring was unavailable."
value: ${{ steps.render.outputs.score }}
total-issues:
description: "Total diagnostic count in the scan report. In baseline mode (pull requests), this is the count of issues introduced by the PR."
value: ${{ steps.render.outputs.total-issues }}
fixed-issues:
description: "Baseline mode (pull requests) only: count of issues the PR resolved. 0 otherwise."
value: ${{ steps.render.outputs.fixed-issues }}
error-count:
description: "Error diagnostic count in the scan report."
value: ${{ steps.render.outputs.error-count }}
warning-count:
description: "Warning diagnostic count in the scan report."
value: ${{ steps.render.outputs.warning-count }}
affected-files:
description: "Number of files with diagnostics."
value: ${{ steps.render.outputs.affected-files }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@v5
with:
node-version: ${{ inputs.node-version }}
package-manager-cache: false
- id: pr-files
if: ${{ github.event_name == 'pull_request' }}
uses: actions/github-script@v8
env:
REACT_DOCTOR_CHANGED_FILES: ${{ runner.temp }}/react-doctor-changed-files.txt
with:
script: |
const fs = require("fs");
const outputPath = process.env.REACT_DOCTOR_CHANGED_FILES;
const pullRequest = context.payload.pull_request;
if (!pullRequest || !outputPath) return;
let files;
try {
files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
} catch (error) {
core.warning(
`React Doctor could not list pull request files (${error.message}). ` +
"Scanning the full project instead. Grant the workflow `pull-requests: read` " +
"permission to scan only the files changed in the pull request.",
);
return;
}
const includedStatuses = new Set(["added", "modified", "renamed"]);
const changedFiles = files
.filter((file) => includedStatuses.has(file.status))
.map((file) => file.filename);
fs.writeFileSync(outputPath, changedFiles.join("\n") + (changedFiles.length ? "\n" : ""));
core.setOutput("path", outputPath);
- id: base
if: ${{ github.event_name == 'pull_request' }}
shell: bash
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
# Fetch the PR base commit so react-doctor can read base file content
# and resolve the merge-base for baseline (PR-introduced-issues-only)
# mode. Depth 1 covers the standard merge-ref checkout (base is a parent
# of HEAD); for deeper history use `fetch-depth: 0` on actions/checkout.
# Best-effort: if it fails, react-doctor degrades to reporting every
# finding in the changed files.
if [ -n "$BASE_SHA" ] && git -C "$INPUT_DIRECTORY" rev-parse --git-dir >/dev/null 2>&1; then
git -C "$INPUT_DIRECTORY" fetch --no-tags --depth=1 origin "$BASE_SHA" 2>/dev/null || true
fi
- id: scan
shell: bash
env:
NO_COLOR: "1"
INPUT_DIRECTORY: ${{ inputs.directory }}
INPUT_PROJECT: ${{ inputs.project }}
INPUT_BLOCKING: ${{ inputs.blocking }}
INPUT_VERSION: ${{ inputs.version }}
CHANGED_FILES_FROM: ${{ steps.pr-files.outputs.path }}
# Forwarded so baseline mode reads base content against a fetched SHA
# (branch names rarely resolve in a shallow PR checkout). Empty off PRs.
REACT_DOCTOR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
RUNNER_TEMP: ${{ runner.temp }}
GITHUB_RUN_ID: ${{ github.run_id }}
# Telemetry: mark runs as launched by the official action and forward the
# inputs the CLI can't otherwise see (comment / review-comments are
# handled in later steps, not as flags). These ride along on the scan's
# Sentry wide event. Anonymous: no repo, owner, actor, or ref.
REACT_DOCTOR_GITHUB_ACTION: ${{ github.action_ref || '1' }}
REACT_DOCTOR_ACTION_BLOCKING: ${{ inputs.blocking }}
REACT_DOCTOR_ACTION_COMMENT: ${{ inputs.comment }}
REACT_DOCTOR_ACTION_REVIEW_COMMENTS: ${{ inputs.review-comments }}
REACT_DOCTOR_ACTION_VERSION: ${{ inputs.version }}
run: |
REPORT_FILE="${RUNNER_TEMP:-/tmp}/react-doctor-report-${GITHUB_RUN_ID:-$$}.json"
echo "report-file=$REPORT_FILE" >> "$GITHUB_OUTPUT"
FLAGS=("--json" "--json-compact")
if [ -n "$INPUT_BLOCKING" ]; then FLAGS+=("--blocking" "$INPUT_BLOCKING"); fi
if [ -n "$INPUT_PROJECT" ]; then FLAGS+=("--project" "$INPUT_PROJECT"); fi
if [ -n "$CHANGED_FILES_FROM" ]; then FLAGS+=("--changed-files-from" "$CHANGED_FILES_FROM"); fi
if [[ "$INPUT_VERSION" = ./* || "$INPUT_VERSION" = ../* || "$INPUT_VERSION" = /* ]]; then
PACKAGE_SPEC="$INPUT_VERSION"
else
PACKAGE_SPEC="react-doctor@$INPUT_VERSION"
fi
set +e
npm exec --yes --package "$PACKAGE_SPEC" -- react-doctor "$INPUT_DIRECTORY" "${FLAGS[@]}" > "$REPORT_FILE"
SCAN_STATUS=$?
set -e
if ! node "$GITHUB_ACTION_PATH/scripts/ensure-json-report.mjs" "$REPORT_FILE" "$SCAN_STATUS"; then
SCAN_STATUS=1
fi
echo "exit-code=$SCAN_STATUS" >> "$GITHUB_OUTPUT"
- id: render
if: always()
shell: bash
env:
REPORT_FILE: ${{ steps.scan.outputs.report-file }}
COMMENT_FILE: ${{ runner.temp }}/react-doctor-comment.md
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# The reviewed commit, shown in the sticky comment's footer. On a PR the
# head SHA is the merge ref's parent; `github.sha` covers push runs.
REACT_DOCTOR_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
node "$GITHUB_ACTION_PATH/scripts/render-github-action-comment.mjs" "$REPORT_FILE" "$COMMENT_FILE"
echo "comment-file=$COMMENT_FILE" >> "$GITHUB_OUTPUT"
- name: Update sticky PR comment
if: ${{ always() && github.event_name == 'pull_request' && inputs.comment == 'true' }}
uses: actions/github-script@v8
env:
REACT_DOCTOR_COMMENT_FILE: ${{ steps.render.outputs.comment-file }}
with:
script: |
const fs = require("fs");
const commentPath = process.env.REACT_DOCTOR_COMMENT_FILE;
if (!commentPath || !fs.existsSync(commentPath)) return;
const body = fs.readFileSync(commentPath, "utf8").trim();
if (!body) return;
const marker = "<!-- react-doctor:summary -->";
try {
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number,
per_page: 100,
});
const previous = comments.find((comment) => comment.body?.startsWith(marker));
if (previous) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: previous.id,
body,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
}
} catch (error) {
core.warning(`React Doctor could not update the sticky PR comment: ${error.message}`);
}
- name: Post inline review comments
if: ${{ always() && github.event_name == 'pull_request' && inputs.review-comments == 'true' }}
uses: actions/github-script@v8
env:
REACT_DOCTOR_REPORT_FILE: ${{ steps.scan.outputs.report-file }}
INPUT_DIRECTORY: ${{ inputs.directory }}
with:
script: |
const fs = require("fs");
const reportPath = process.env.REACT_DOCTOR_REPORT_FILE;
const pullRequest = context.payload.pull_request;
if (!pullRequest || !reportPath || !fs.existsSync(reportPath)) return;
let report;
try {
report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
} catch {
return;
}
// A failed/aborted scan emits `ok: false` with an empty `diagnostics`
// array (CLI error reports + the ensure-json-report fallback). Don't
// treat that as a clean scan — leave any existing inline feedback in
// place rather than wiping it on a transient failure.
if (report.ok === false) return;
const diagnostics = Array.isArray(report.diagnostics) ? report.diagnostics : [];
const marker = "<!-- react-doctor:review -->";
const directoryPrefix = (process.env.INPUT_DIRECTORY || ".")
.replace(/^\.\/?/, "")
.replace(/\/$/, "");
let files;
try {
files = await github.paginate(github.rest.pulls.listFiles, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
} catch (error) {
core.warning(
`React Doctor could not list pull request files for review comments (${error.message}).`,
);
return;
}
// The Reviews API accepts RIGHT-side comments on any line present in a
// diff hunk — added (`+`) AND unchanged context (` `) lines (a
// PR-introduced diagnostic can land on a context line when the change
// that triggered it is elsewhere in the hunk). Removed (`-`) lines and
// the `\ No newline at end of file` marker aren't RIGHT-side lines.
const commentableLinesByFile = new Map();
for (const file of files) {
if (!file.patch) continue;
const commentableLines = new Set();
let newLine = 0;
for (const patchLine of file.patch.split("\n")) {
const hunkHeader = patchLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (hunkHeader) {
newLine = Number(hunkHeader[1]);
continue;
}
if (patchLine.startsWith("+") || patchLine.startsWith(" ")) {
commentableLines.add(newLine);
newLine += 1;
}
}
commentableLinesByFile.set(file.filename, commentableLines);
}
const toRepoPath = (filePath) => {
const normalized = String(filePath || "")
.replace(/\\/g, "/")
.replace(/^\.\//, "");
if (commentableLinesByFile.has(normalized)) return normalized;
const prefixed = directoryPrefix ? `${directoryPrefix}/${normalized}` : normalized;
if (commentableLinesByFile.has(prefixed)) return prefixed;
const suffixMatches = [...commentableLinesByFile.keys()].filter((name) =>
name.endsWith(`/${normalized}`),
);
return suffixMatches.length === 1 ? suffixMatches[0] : null;
};
const seen = new Set();
const comments = [];
for (const diagnostic of diagnostics) {
if (!diagnostic || typeof diagnostic.line !== "number" || diagnostic.line <= 0) continue;
const repoPath = toRepoPath(diagnostic.filePath);
if (!repoPath) continue;
const commentableLines = commentableLinesByFile.get(repoPath);
if (!commentableLines || !commentableLines.has(diagnostic.line)) continue;
const ruleKey = `${diagnostic.plugin}/${diagnostic.rule}`;
const dedupeKey = `${repoPath}:${diagnostic.line}:${ruleKey}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
const severity = diagnostic.severity === "error" ? "error" : "warning";
const fixLine = diagnostic.help ? `\n\n**Fix** → ${diagnostic.help}` : "";
const docsLink = `\n\n[Docs](https://react.doctor/docs/rules/${ruleKey})`;
comments.push({
path: repoPath,
line: diagnostic.line,
side: "RIGHT",
body: `${marker}\n**React Doctor** · \`${ruleKey}\` _(${severity})_\n\n${diagnostic.message}${fixLine}${docsLink}`,
});
}
// A scan that found issues but mapped none onto changed lines (path
// mismatch, missing patch, line outside the diff hunks) must not wipe
// existing inline feedback — leave it untouched and bail.
if (comments.length === 0 && diagnostics.length > 0) return;
// Capture the previous run's review comments up front so they can be
// cleared AFTER a successful post — never before, so a failed post
// can't wipe prior feedback (it just leaves the last run's comments).
let priorCommentIds = [];
try {
const existing = await github.paginate(github.rest.pulls.listReviewComments, {
...context.repo,
pull_number: pullRequest.number,
per_page: 100,
});
priorCommentIds = existing
.filter((comment) => comment.body && comment.body.startsWith(marker))
.map((comment) => comment.id);
} catch (error) {
core.warning(`React Doctor could not list prior review comments: ${error.message}`);
}
// Inline comments are intentionally capped to avoid flooding a PR and
// to stay within GitHub's per-review size limits. When more than this
// anchor on the diff, the sticky summary comment remains the complete
// record of every finding; the inline subset is just the first slice.
const maxComments = 50;
if (comments.length > 0) {
try {
await github.rest.pulls.createReview({
...context.repo,
pull_number: pullRequest.number,
event: "COMMENT",
comments: comments.slice(0, maxComments),
});
} catch (error) {
// Keep the prior comments rather than clearing them, so a failed
// post doesn't leave the PR with no inline feedback.
core.warning(`React Doctor could not post inline review comments: ${error.message}`);
return;
}
}
// Clear the previous run's comments now that the new review posted (or
// the scan came back genuinely clean) so re-runs don't stack.
for (const commentId of priorCommentIds) {
try {
await github.rest.pulls.deleteReviewComment({
...context.repo,
comment_id: commentId,
});
} catch (error) {
core.warning(`React Doctor could not clear a prior review comment: ${error.message}`);
}
}
- name: Fail if React Doctor found blocking issues
if: always()
shell: bash
env:
SCAN_STATUS: ${{ steps.scan.outputs.exit-code }}
run: |
# The CLI's exit code already reflects the `blocking` threshold
# (`blocking: none` exits 0 even with findings); just propagate it.
exit "${SCAN_STATUS:-1}"