Files
millionco__react-doctor/action.yml
T
Aiden Bai 53e2544870 feat(action): expose --annotations through action.yml (#281)
* feat(action): expose --annotations through action.yml

Adds an opt-in 'annotations' input to the composite action so users
can surface findings as inline GitHub Actions annotations on the PR's
Files changed view. The flag is forwarded to the CLI as --annotations.

When 'github-token' is also set, the captured PR-comment body now
filters out '::error' / '::warning' workflow-command lines so the
sticky summary stays human-readable while the runner still parses
annotations from the live step log.

README documents the three resulting modes: sticky-comment-only
(default), annotations-only, and both.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* docs: tighten action annotations docs, remove em dashes

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* test(action): update contract test for split tee/sed and annotations

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-16 20:43:13 -07:00

160 lines
6.6 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: "."
verbose:
description: "Show file details per rule"
default: "true"
project:
description: "Workspace project(s) to scan (comma-separated)"
required: false
diff:
description: "Base branch for diff mode (e.g. main). Only files changed vs this branch are scanned."
required: false
github-token:
description: "GitHub token for posting PR comments. When set on pull_request events, findings are posted as a PR comment."
required: false
fail-on:
description: "Exit non-zero when diagnostics fire (error, warning, none). Gates on individual diagnostics in the scanned files, not on the score. Combine with `diff` for regression-only gating, or read the `score` output in a follow-up step to enforce an absolute score floor."
default: "error"
offline:
description: "Skip sending diagnostics to the react.doctor API and calculate score locally"
default: "false"
annotations:
description: "Emit diagnostics as GitHub Actions annotations so findings show inline in the PR's Files changed view. Combine with `github-token` for both annotations and a sticky comment."
default: "false"
node-version:
description: "Node.js version to use"
default: "22"
outputs:
score:
description: "Health score (0-100)"
value: ${{ steps.score.outputs.score }}
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- if: ${{ inputs.diff != '' && github.event_name == 'pull_request' }}
shell: bash
run: |
case "$DIFF_BASE" in -* ) echo "::error::diff input cannot start with -"; exit 1 ;; esac
case "$HEAD_REF" in -* ) echo "::error::head_ref cannot start with -"; exit 1 ;; esac
git fetch origin "$DIFF_BASE" && git branch -f "$DIFF_BASE" FETCH_HEAD 2>/dev/null || true
git checkout "$HEAD_REF" 2>/dev/null || true
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
DIFF_BASE: ${{ inputs.diff }}
HEAD_REF: ${{ github.head_ref }}
- shell: bash
env:
NO_COLOR: "1"
INPUT_DIRECTORY: ${{ inputs.directory }}
INPUT_VERBOSE: ${{ inputs.verbose }}
INPUT_PROJECT: ${{ inputs.project }}
INPUT_DIFF: ${{ inputs.diff }}
INPUT_GITHUB_TOKEN: ${{ inputs.github-token }}
INPUT_FAIL_ON: ${{ inputs.fail-on }}
INPUT_OFFLINE: ${{ inputs.offline }}
INPUT_ANNOTATIONS: ${{ inputs.annotations }}
RUNNER_TEMP: ${{ runner.temp }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: |
FLAGS=("--fail-on" "$INPUT_FAIL_ON")
if [ "$INPUT_VERBOSE" = "true" ]; then FLAGS+=("--verbose"); fi
if [ -n "$INPUT_PROJECT" ]; then FLAGS+=("--project" "$INPUT_PROJECT"); fi
if [ -n "$INPUT_DIFF" ]; then FLAGS+=("--diff" "$INPUT_DIFF"); fi
if [ "$INPUT_OFFLINE" = "true" ]; then FLAGS+=("--offline"); fi
if [ "$INPUT_ANNOTATIONS" = "true" ]; then FLAGS+=("--annotations"); fi
OUTPUT_FILE="${RUNNER_TEMP:-/tmp}/react-doctor-output-${GITHUB_RUN_ID:-$$}.txt"
echo "REACT_DOCTOR_OUTPUT_FILE=$OUTPUT_FILE" >> "$GITHUB_ENV"
if [ -n "$INPUT_GITHUB_TOKEN" ]; then
# --pr-comment demotes weak-signal rule families (default:
# `design` tag) from the printed output and the fail-on gate
# so style cleanup doesn't dilute meaningful React findings
# in the sticky PR comment. Configure overrides via
# config.surfaces.{prComment,ciFailure} in react-doctor.config.json.
RAW_FILE="${RUNNER_TEMP:-/tmp}/react-doctor-raw-${GITHUB_RUN_ID:-$$}.txt"
npx react-doctor@latest "$INPUT_DIRECTORY" "${FLAGS[@]}" --pr-comment | tee "$RAW_FILE"
# Strip annotation workflow commands from the PR-comment body;
# the runner still parses them from the live step log.
sed -E '/^::(error|warning) /d' "$RAW_FILE" > "$OUTPUT_FILE"
else
npx react-doctor@latest "$INPUT_DIRECTORY" "${FLAGS[@]}"
fi
- id: score
if: always()
shell: bash
env:
INPUT_DIRECTORY: ${{ inputs.directory }}
INPUT_OFFLINE: ${{ inputs.offline }}
run: |
# HACK: --score is an output-collection step, not a gate. Force
# --fail-on none so older react-doctor releases (which exit
# non-zero when the score lands in the "Needs work" band, even
# though the value itself is the only meaningful signal here)
# don't fail the composite action under `set -e -o pipefail`.
SCORE_ARGS=("$INPUT_DIRECTORY" "--score" "--fail-on" "none")
if [ "$INPUT_OFFLINE" = "true" ]; then SCORE_ARGS+=("--offline"); fi
SCORE=$(npx react-doctor@latest "${SCORE_ARGS[@]}" 2>/dev/null | tail -1 | tr -d '[:space:]') || true
if [[ -n "$SCORE" && "$SCORE" =~ ^[0-9]+$ ]]; then
echo "score=$SCORE" >> "$GITHUB_OUTPUT"
fi
- if: ${{ inputs.github-token != '' && github.event_name == 'pull_request' }}
uses: actions/github-script@v7
env:
REACT_DOCTOR_OUTPUT_FILE: ${{ env.REACT_DOCTOR_OUTPUT_FILE }}
REACT_DOCTOR_SCORE: ${{ steps.score.outputs.score }}
with:
github-token: ${{ inputs.github-token }}
script: |
const fs = require("fs");
const path = process.env.REACT_DOCTOR_OUTPUT_FILE;
if (!path || !fs.existsSync(path)) return;
const output = fs.readFileSync(path, "utf8").trim();
if (!output) return;
const score = process.env.REACT_DOCTOR_SCORE;
const scoreLine =
score && /^[0-9]+$/.test(score)
? `**Score:** \`${score}\` / 100\n\n`
: "";
const marker = "<!-- react-doctor -->";
const fence = "````";
const body = `${marker}\n## React Doctor\n\n${scoreLine}${fence}\n${output}\n${fence}`;
const { data: comments } = await github.rest.issues.listComments({
...context.repo,
issue_number: context.issue.number,
});
const prev = comments.find((c) => c.body?.startsWith(marker));
if (prev) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: prev.id,
body,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.issue.number,
body,
});
}