variables:
GIT_STRATEGY: "fetch"
GIT_DEPTH: "0"
FALLOW_VERSION: "" FALLOW_COMMAND: "" FALLOW_ROOT: "."
FALLOW_CONFIG: "" FALLOW_PRODUCTION: "" FALLOW_PRODUCTION_DEAD_CODE: "" FALLOW_PRODUCTION_HEALTH: "" FALLOW_PRODUCTION_DUPES: "" FALLOW_FAIL_ON_ISSUES: "true"
FALLOW_ARGS: "" FALLOW_COMMENT: "false" FALLOW_REVIEW: "false" FALLOW_CODEQUALITY: "true" FALLOW_MAX_COMMENTS: "50"
FALLOW_CHANGED_SINCE: "" FALLOW_BASELINE: ""
FALLOW_SAVE_BASELINE: ""
FALLOW_WORKSPACE: ""
FALLOW_CHANGED_WORKSPACES: ""
FALLOW_ISSUE_TYPES: "" FALLOW_FAIL_ON_REGRESSION: "false"
FALLOW_TOLERANCE: "0"
FALLOW_REGRESSION_BASELINE: ""
FALLOW_SAVE_REGRESSION_BASELINE: ""
FALLOW_DUPES_MODE: "mild" FALLOW_MIN_TOKENS: ""
FALLOW_MIN_LINES: ""
FALLOW_THRESHOLD: "" FALLOW_SKIP_LOCAL: "false"
FALLOW_CROSS_LANGUAGE: "false"
FALLOW_IGNORE_IMPORTS: "false"
FALLOW_MAX_CYCLOMATIC: ""
FALLOW_MAX_COGNITIVE: ""
FALLOW_MAX_CRAP: "" FALLOW_PRODUCTION_COVERAGE: "" FALLOW_COVERAGE_ROOT: "" FALLOW_MIN_INVOCATIONS_HOT: "" FALLOW_MIN_OBSERVATION_VOLUME: "" FALLOW_LOW_TRAFFIC_THRESHOLD: "" FALLOW_TOP: ""
FALLOW_SORT: "" FALLOW_SCORE: "false" FALLOW_FILE_SCORES: "false"
FALLOW_HOTSPOTS: "false"
FALLOW_TARGETS: "false"
FALLOW_COMPLEXITY: "false"
FALLOW_SINCE: ""
FALLOW_MIN_COMMITS: ""
FALLOW_SAVE_SNAPSHOT: "" FALLOW_TREND: "false"
FALLOW_DRY_RUN: "true"
FALLOW_NO_CACHE: "false"
FALLOW_THREADS: ""
FALLOW_ONLY: "" FALLOW_SKIP: ""
FALLOW_SCRIPTS_REF: ""
.fallow:
image: node:22-alpine
stage: test
cache:
key: "fallow-${CI_COMMIT_REF_SLUG}"
paths:
- .fallow/
policy: pull-push
before_script:
- |
if command -v apk > /dev/null 2>&1; then
apk add --no-cache bash jq git curl > /dev/null 2>&1
elif command -v apt-get > /dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq bash jq git curl > /dev/null 2>&1
else
echo "ERROR: No supported package manager found (apk or apt-get required)"
exit 2
fi
- |
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
is_safe_version_spec() {
local spec
spec="$(trim "$1")"
if [ "$spec" = "latest" ]; then
return 0
fi
local start_re='^[0-9xX*~^<>=]'
local safe_re='^[0-9A-Za-z.*~^<>=| -]+$'
# Accept semver versions and ranges, while rejecting protocols, paths,
# package aliases, git URLs, or injected npm arguments.
[[ "$spec" =~ $start_re ]] &&
[[ "$spec" =~ $safe_re ]] &&
[[ ! "$spec" =~ : ]] &&
[[ ! "$spec" =~ / ]] &&
[[ ! "$spec" =~ [[:space:]]-[A-Za-z] ]]
}
is_exact_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?$ ]]
}
project_fallow_spec() {
local package_json="$1/package.json"
if [ ! -f "$package_json" ]; then
return 0
fi
node - "$package_json" <<'NODE'
const fs = require("node:fs");
const packageJson = process.argv[2];
const pkg = JSON.parse(fs.readFileSync(packageJson, "utf8"));
for (const section of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
const spec = pkg[section]?.fallow;
if (typeof spec === "string" && spec.trim()) {
console.log(spec.trim());
process.exit(0);
}
}
NODE
}
requested_version="$(trim "${FALLOW_VERSION:-}")"
root="${FALLOW_ROOT:-.}"
project_spec="$(project_fallow_spec "$root" 2>/dev/null || true)"
project_spec="$(trim "$project_spec")"
install_spec=""
if [ -n "$requested_version" ]; then
install_spec="$requested_version"
echo "Using fallow version from FALLOW_VERSION: ${install_spec}"
elif [ -n "$project_spec" ]; then
if is_safe_version_spec "$project_spec"; then
install_spec="$project_spec"
echo "Using fallow version from ${root}/package.json: ${install_spec}"
else
echo "WARNING: Ignoring unsupported fallow package.json spec '${project_spec}'. Use a semver version or range, or set FALLOW_VERSION explicitly."
install_spec="latest"
fi
else
install_spec="latest"
fi
if ! is_safe_version_spec "$install_spec"; then
echo "ERROR: Invalid version specifier: ${install_spec}. Use 'latest' or a semver version/range like '2.52.2' or '^2.52.0'."
exit 2
fi
printf '%s\n' "$install_spec" > /tmp/fallow-version-spec
if [ "$install_spec" = "latest" ]; then
install_arg="fallow"
else
install_arg="fallow@${install_spec}"
fi
# FALLOW_INSTALL_DRY_RUN is an internal hook used by ci/tests/run.sh to
# exercise this block without invoking npm. Not a documented user knob.
if [ "${FALLOW_INSTALL_DRY_RUN:-}" = "true" ]; then
echo "DRY RUN: npm install -g ${install_arg}"
exit 0
fi
npm install -g "$install_arg" || { echo "ERROR: Failed to install ${install_arg}"; exit 2; }
installed_version="$(fallow --version 2>/dev/null || echo 'unknown version')"
echo "Installed fallow ${installed_version}"
if [ -z "$requested_version" ] && [ -n "$project_spec" ] && is_exact_version "$project_spec"; then
installed_semver="$(printf '%s\n' "$installed_version" | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?' | head -n 1 || true)"
if [ -n "$installed_semver" ] && [ "$installed_semver" != "$project_spec" ]; then
echo "WARNING: Installed fallow ${installed_semver}, but ${root}/package.json pins ${project_spec}. Set FALLOW_VERSION or align package.json to keep local and CI results comparable."
fi
fi
- |
export FALLOW_JQ_DIR="/tmp/fallow-jq"
export FALLOW_SHARED_JQ_DIR="/tmp/fallow-shared-jq"
FALLOW_SCRIPTS_DIR="/tmp/fallow-scripts"
mkdir -p "$FALLOW_JQ_DIR" "$FALLOW_SHARED_JQ_DIR" "$FALLOW_SCRIPTS_DIR"
if [ "$FALLOW_COMMENT" = "true" ] || [ "$FALLOW_REVIEW" = "true" ]; then
DOWNLOAD_FAILURES=0
if [ -d "ci/jq" ] && [ -d "action/jq" ] && [ -d "ci/scripts" ]; then
echo "Using vendored MR integration scripts from the repository checkout..."
for f in summary-check.jq summary-health.jq summary-combined.jq review-comments-dupes.jq; do
cp "ci/jq/${f}" "${FALLOW_JQ_DIR}/${f}" 2>/dev/null || {
echo " WARNING: Failed to copy ci/jq/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
}
done
for f in summary-dupes.jq summary-fix.jq review-comments-check.jq review-comments-health.jq review-body.jq merge-comments.jq filter-changed.jq; do
cp "action/jq/${f}" "${FALLOW_SHARED_JQ_DIR}/${f}" 2>/dev/null || {
echo " WARNING: Failed to copy action/jq/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
}
done
for f in comment.sh review.sh; do
if cp "ci/scripts/${f}" "${FALLOW_SCRIPTS_DIR}/${f}" 2>/dev/null; then
chmod +x "${FALLOW_SCRIPTS_DIR}/${f}"
else
echo " WARNING: Failed to copy ci/scripts/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
fi
done
else
FALLOW_RESOLVED_VERSION="$(cat /tmp/fallow-version-spec 2>/dev/null || printf '%s' "${FALLOW_VERSION:-}")"
if [ -z "$FALLOW_SCRIPTS_REF" ] && echo "$FALLOW_RESOLVED_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-.][a-zA-Z0-9.]+)?$'; then
FALLOW_SCRIPTS_REF="v${FALLOW_RESOLVED_VERSION}"
fi
if [ -z "$FALLOW_SCRIPTS_REF" ]; then
echo "ERROR: FALLOW_COMMENT/FALLOW_REVIEW require vendored ci/ + action/ scripts or a pinned FALLOW_SCRIPTS_REF when fallow is installed from latest or a semver range."
exit 2
fi
if ! echo "$FALLOW_SCRIPTS_REF" | grep -qE '^[a-zA-Z0-9._/-]+$'; then
echo "ERROR: Invalid FALLOW_SCRIPTS_REF: ${FALLOW_SCRIPTS_REF}"; exit 2
fi
FALLOW_SCRIPTS_BASE="https://raw.githubusercontent.com/fallow-rs/fallow/${FALLOW_SCRIPTS_REF}"
echo "Downloading MR integration scripts pinned to ${FALLOW_SCRIPTS_REF}..."
for f in summary-check.jq summary-health.jq summary-combined.jq review-comments-dupes.jq; do
curl -sf "${FALLOW_SCRIPTS_BASE}/ci/jq/${f}" -o "${FALLOW_JQ_DIR}/${f}" 2>/dev/null || {
echo " WARNING: Failed to download ci/jq/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
}
done
for f in summary-dupes.jq summary-fix.jq review-comments-check.jq review-comments-health.jq review-body.jq merge-comments.jq filter-changed.jq; do
curl -sf "${FALLOW_SCRIPTS_BASE}/action/jq/${f}" -o "${FALLOW_SHARED_JQ_DIR}/${f}" 2>/dev/null || {
echo " WARNING: Failed to download action/jq/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
}
done
for f in comment.sh review.sh; do
if curl -sf "${FALLOW_SCRIPTS_BASE}/ci/scripts/${f}" -o "${FALLOW_SCRIPTS_DIR}/${f}" 2>/dev/null; then
chmod +x "${FALLOW_SCRIPTS_DIR}/${f}"
else
echo " WARNING: Failed to download ci/scripts/${f}"
DOWNLOAD_FAILURES=$((DOWNLOAD_FAILURES + 1))
fi
done
fi
if [ "$DOWNLOAD_FAILURES" -gt 0 ]; then
echo "WARNING: ${DOWNLOAD_FAILURES} script(s) failed to download — MR comments/review may be limited"
else
echo "Scripts downloaded"
fi
fi
- |
cat > /tmp/fallow-run.sh << 'FALLOW_SCRIPT_EOF'
#!/bin/bash
set -euo pipefail
# ── Validate inputs ──────────────────────────────────────────────
case "$FALLOW_COMMAND" in
""|dead-code|check|dupes|health|fix) ;;
*) echo "ERROR: Invalid command: ${FALLOW_COMMAND}"; exit 2 ;;
esac
for name_val in "min-tokens:$FALLOW_MIN_TOKENS" "min-lines:$FALLOW_MIN_LINES" \
"max-cyclomatic:$FALLOW_MAX_CYCLOMATIC" "max-cognitive:$FALLOW_MAX_COGNITIVE" \
"top:$FALLOW_TOP" "min-commits:$FALLOW_MIN_COMMITS" "threads:$FALLOW_THREADS" \
"min-invocations-hot:$FALLOW_MIN_INVOCATIONS_HOT" "min-observation-volume:$FALLOW_MIN_OBSERVATION_VOLUME"; do
name="${name_val%%:*}"; val="${name_val#*:}"
if [ -n "$val" ] && ! echo "$val" | grep -qE '^[0-9]+$'; then
echo "ERROR: ${name} must be a positive integer, got: ${val}"; exit 2
fi
done
if [ -n "$FALLOW_THRESHOLD" ] && ! echo "$FALLOW_THRESHOLD" | grep -qE '^[0-9]+\.?[0-9]*$'; then
echo "ERROR: threshold must be a number, got: ${FALLOW_THRESHOLD}"; exit 2
fi
# max-crap accepts floating-point values; CRAP scores are non-integer.
if [ -n "$FALLOW_MAX_CRAP" ] && ! echo "$FALLOW_MAX_CRAP" | grep -qE '^[0-9]+\.?[0-9]*$'; then
echo "ERROR: max-crap must be a non-negative number, got: ${FALLOW_MAX_CRAP}"; exit 2
fi
if [ -n "$FALLOW_LOW_TRAFFIC_THRESHOLD" ] && ! echo "$FALLOW_LOW_TRAFFIC_THRESHOLD" | grep -qE '^[0-9]+\.?[0-9]*$'; then
echo "ERROR: low-traffic-threshold must be a non-negative number, got: ${FALLOW_LOW_TRAFFIC_THRESHOLD}"; exit 2
fi
# ── Auto changed-since in MR context ───────────────────────────
if [ -n "${CI_MERGE_REQUEST_IID:-}" ] && [ -z "$FALLOW_CHANGED_SINCE" ] && [ "$FALLOW_COMMAND" != "fix" ]; then
if [ -n "${CI_MERGE_REQUEST_DIFF_BASE_SHA:-}" ]; then
FALLOW_CHANGED_SINCE="$CI_MERGE_REQUEST_DIFF_BASE_SHA"
echo "Auto-scoping to changed files (--changed-since ${FALLOW_CHANGED_SINCE:0:12})"
fi
fi
# ── Build CLI arguments ──────────────────────────────────────────
# Primary run uses --format json; CodeClimate report is generated via
# a second run with --format codeclimate when FALLOW_CODEQUALITY=true.
ARGS=()
[ -n "$FALLOW_COMMAND" ] && ARGS+=("$FALLOW_COMMAND")
ARGS+=(--root "$FALLOW_ROOT" --quiet --format json)
[ -n "$FALLOW_CONFIG" ] && ARGS+=(--config "$FALLOW_CONFIG")
[ "$FALLOW_PRODUCTION" = "true" ] && ARGS+=(--production)
if [ -z "$FALLOW_COMMAND" ]; then
[ "$FALLOW_PRODUCTION_DEAD_CODE" = "true" ] && ARGS+=(--production-dead-code)
[ "$FALLOW_PRODUCTION_HEALTH" = "true" ] && ARGS+=(--production-health)
[ "$FALLOW_PRODUCTION_DUPES" = "true" ] && ARGS+=(--production-dupes)
fi
[ -n "$FALLOW_CHANGED_SINCE" ] && ARGS+=(--changed-since "$FALLOW_CHANGED_SINCE")
[ -n "$FALLOW_BASELINE" ] && ARGS+=(--baseline "$FALLOW_BASELINE")
[ -n "$FALLOW_SAVE_BASELINE" ] && ARGS+=(--save-baseline "$FALLOW_SAVE_BASELINE")
[ -n "$FALLOW_WORKSPACE" ] && ARGS+=(--workspace "$FALLOW_WORKSPACE")
[ -n "$FALLOW_CHANGED_WORKSPACES" ] && ARGS+=(--changed-workspaces "$FALLOW_CHANGED_WORKSPACES")
[ "$FALLOW_NO_CACHE" = "true" ] && ARGS+=(--no-cache)
[ -n "$FALLOW_THREADS" ] && ARGS+=(--threads "$FALLOW_THREADS")
if [ -z "$FALLOW_COMMAND" ]; then
[ -n "$FALLOW_ONLY" ] && ARGS+=(--only "$FALLOW_ONLY")
[ -n "$FALLOW_SKIP" ] && ARGS+=(--skip "$FALLOW_SKIP")
fi
case "$FALLOW_COMMAND" in
dead-code|check)
if [ -n "$FALLOW_ISSUE_TYPES" ]; then
IFS=',' read -ra TYPES <<< "$FALLOW_ISSUE_TYPES"
for t in "${TYPES[@]}"; do
t="$(echo "$t" | xargs)"
ARGS+=("--${t}")
done
fi
[ "$FALLOW_FAIL_ON_REGRESSION" = "true" ] && ARGS+=(--fail-on-regression)
[ -n "$FALLOW_TOLERANCE" ] && [ "$FALLOW_TOLERANCE" != "0" ] && ARGS+=(--tolerance "$FALLOW_TOLERANCE")
[ -n "$FALLOW_REGRESSION_BASELINE" ] && ARGS+=(--regression-baseline "$FALLOW_REGRESSION_BASELINE")
[ -n "$FALLOW_SAVE_REGRESSION_BASELINE" ] && ARGS+=(--save-regression-baseline "$FALLOW_SAVE_REGRESSION_BASELINE")
;;
dupes)
ARGS+=(--mode "$FALLOW_DUPES_MODE")
[ -n "$FALLOW_MIN_TOKENS" ] && ARGS+=(--min-tokens "$FALLOW_MIN_TOKENS")
[ -n "$FALLOW_MIN_LINES" ] && ARGS+=(--min-lines "$FALLOW_MIN_LINES")
[ -n "$FALLOW_THRESHOLD" ] && ARGS+=(--threshold "$FALLOW_THRESHOLD")
[ "$FALLOW_SKIP_LOCAL" = "true" ] && ARGS+=(--skip-local)
[ "$FALLOW_CROSS_LANGUAGE" = "true" ] && ARGS+=(--cross-language)
[ "$FALLOW_IGNORE_IMPORTS" = "true" ] && ARGS+=(--ignore-imports)
[ -n "$FALLOW_TOP" ] && ARGS+=(--top "$FALLOW_TOP")
;;
health)
[ -n "$FALLOW_MAX_CYCLOMATIC" ] && ARGS+=(--max-cyclomatic "$FALLOW_MAX_CYCLOMATIC")
[ -n "$FALLOW_MAX_COGNITIVE" ] && ARGS+=(--max-cognitive "$FALLOW_MAX_COGNITIVE")
[ -n "$FALLOW_MAX_CRAP" ] && ARGS+=(--max-crap "$FALLOW_MAX_CRAP")
[ -n "$FALLOW_PRODUCTION_COVERAGE" ] && ARGS+=(--runtime-coverage "$FALLOW_PRODUCTION_COVERAGE")
[ -n "$FALLOW_COVERAGE_ROOT" ] && ARGS+=(--coverage-root "$FALLOW_COVERAGE_ROOT")
[ -n "$FALLOW_MIN_INVOCATIONS_HOT" ] && ARGS+=(--min-invocations-hot "$FALLOW_MIN_INVOCATIONS_HOT")
[ -n "$FALLOW_MIN_OBSERVATION_VOLUME" ] && ARGS+=(--min-observation-volume "$FALLOW_MIN_OBSERVATION_VOLUME")
[ -n "$FALLOW_LOW_TRAFFIC_THRESHOLD" ] && ARGS+=(--low-traffic-threshold "$FALLOW_LOW_TRAFFIC_THRESHOLD")
[ -n "$FALLOW_TOP" ] && ARGS+=(--top "$FALLOW_TOP")
[ -n "$FALLOW_SORT" ] && ARGS+=(--sort "$FALLOW_SORT")
[ "$FALLOW_SCORE" = "true" ] && ARGS+=(--score)
[ "$FALLOW_FILE_SCORES" = "true" ] && ARGS+=(--file-scores)
[ "$FALLOW_HOTSPOTS" = "true" ] && ARGS+=(--hotspots)
[ "$FALLOW_TARGETS" = "true" ] && ARGS+=(--targets)
[ "$FALLOW_COMPLEXITY" = "true" ] && ARGS+=(--complexity)
[ -n "$FALLOW_SINCE" ] && ARGS+=(--since "$FALLOW_SINCE")
[ -n "$FALLOW_MIN_COMMITS" ] && ARGS+=(--min-commits "$FALLOW_MIN_COMMITS")
if [ -n "$FALLOW_SAVE_SNAPSHOT" ]; then
if [ "$FALLOW_SAVE_SNAPSHOT" = "true" ]; then
ARGS+=(--save-snapshot)
else
ARGS+=(--save-snapshot "$FALLOW_SAVE_SNAPSHOT")
fi
fi
[ "$FALLOW_TREND" = "true" ] && ARGS+=(--trend)
;;
fix)
[ "$FALLOW_DRY_RUN" = "true" ] && ARGS+=(--dry-run) || ARGS+=(--yes)
;;
"")
ARGS+=(--dupes-mode "$FALLOW_DUPES_MODE")
[ -n "$FALLOW_THRESHOLD" ] && ARGS+=(--dupes-threshold "$FALLOW_THRESHOLD")
[ "$FALLOW_SCORE" = "true" ] && ARGS+=(--score)
[ "$FALLOW_TREND" = "true" ] && ARGS+=(--trend)
if [ -n "$FALLOW_SAVE_SNAPSHOT" ]; then
if [ "$FALLOW_SAVE_SNAPSHOT" = "true" ]; then
ARGS+=(--save-snapshot)
else
ARGS+=(--save-snapshot "$FALLOW_SAVE_SNAPSHOT")
fi
fi
;;
esac
EXTRA_ARGS=()
if [ -n "$FALLOW_ARGS" ]; then
read -ra EXTRA_ARGS <<< "$FALLOW_ARGS"
fi
# ── Run analysis ─────────────────────────────────────────────────
echo "Running: fallow ${ARGS[*]} ${EXTRA_ARGS[*]}"
if ! fallow "${ARGS[@]}" "${EXTRA_ARGS[@]}" > fallow-results.json 2> fallow-stderr.log; then
if [ ! -s fallow-results.json ] || ! jq -e '.' fallow-results.json > /dev/null 2>&1; then
echo "ERROR: Fallow failed to run"
[ -s fallow-stderr.log ] && cat fallow-stderr.log
[ -s fallow-results.json ] && cat fallow-results.json
exit 2
fi
fi
if [ -s fallow-stderr.log ]; then
echo "--- fallow stderr ---"
cat fallow-stderr.log
echo "---"
fi
# ── Extract issue count ──────────────────────────────────────────
case "$FALLOW_COMMAND" in
dead-code|check) ISSUES=$(jq -r '.total_issues // 0' fallow-results.json) ;;
dupes) ISSUES=$(jq -r '.stats.clone_groups // 0' fallow-results.json) ;;
health) ISSUES=$(jq -r '((.summary.functions_above_threshold // 0) + ((.runtime_coverage.findings // []) | map(select(.verdict == "safe_to_delete" or .verdict == "review_required" or .verdict == "low_traffic")) | length))' fallow-results.json) ;;
fix) ISSUES=$(jq -r '(.fixes | length)' fallow-results.json) ;;
"") ISSUES=$(jq -r '((.check.total_issues // 0) + (.dupes.stats.clone_groups // 0) + (.health.summary.functions_above_threshold // 0) + ((.health.runtime_coverage.findings // []) | map(select(.verdict == "safe_to_delete" or .verdict == "review_required" or .verdict == "low_traffic")) | length))' fallow-results.json) ;;
esac
if ! echo "$ISSUES" | grep -qE '^[0-9]+$'; then
echo "ERROR: Unexpected issue count: ${ISSUES}"; exit 2
fi
echo "Found ${ISSUES} issues"
# ── GitLab Code Quality report (CodeClimate format) ──────────────
# Uses fallow's native --format codeclimate for inline MR annotations.
if [ "$FALLOW_CODEQUALITY" = "true" ] && [ "$FALLOW_COMMAND" != "fix" ]; then
echo "Generating Code Quality report..."
# Re-run with --format codeclimate instead of json
CQ_ARGS=()
for arg in "${ARGS[@]}"; do
if [ "$arg" = "json" ] && [ "${prev_arg:-}" = "--format" ]; then
CQ_ARGS+=("codeclimate")
else
CQ_ARGS+=("$arg")
fi
prev_arg="$arg"
done
if ! fallow "${CQ_ARGS[@]}" "${EXTRA_ARGS[@]}" > gl-code-quality-report.json 2>/dev/null; then
# Fallback: empty report on failure
echo "[]" > gl-code-quality-report.json
fi
CQ_COUNT=$(jq '. | length' gl-code-quality-report.json 2>/dev/null || echo 0)
echo "Code Quality report: ${CQ_COUNT} findings"
else
echo "[]" > gl-code-quality-report.json
fi
# ── MR summary comment ──────────────────────────────────────────
if [ "$FALLOW_COMMENT" = "true" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ]; then
if [ -x "/tmp/fallow-scripts/comment.sh" ]; then
echo "Posting MR summary comment..."
export FALLOW_JQ_DIR="${FALLOW_JQ_DIR:-/tmp/fallow-jq}"
export FALLOW_SHARED_JQ_DIR="${FALLOW_SHARED_JQ_DIR:-/tmp/fallow-shared-jq}"
export CHANGED_SINCE="$FALLOW_CHANGED_SINCE"
export INPUT_ROOT="${FALLOW_ROOT:-.}"
bash /tmp/fallow-scripts/comment.sh || echo "WARNING: MR comment failed"
else
echo "WARNING: comment.sh not available — skipping MR comment"
fi
fi
# ── Inline MR review discussions ─────────────────────────────────
if [ "$FALLOW_REVIEW" = "true" ] && [ -n "${CI_MERGE_REQUEST_IID:-}" ] && [ "$FALLOW_COMMAND" != "fix" ]; then
if [ -x "/tmp/fallow-scripts/review.sh" ]; then
echo "Posting inline MR review..."
export FALLOW_JQ_DIR="${FALLOW_JQ_DIR:-/tmp/fallow-jq}"
export FALLOW_SHARED_JQ_DIR="${FALLOW_SHARED_JQ_DIR:-/tmp/fallow-shared-jq}"
export MAX_COMMENTS="$FALLOW_MAX_COMMENTS"
export CHANGED_SINCE="$FALLOW_CHANGED_SINCE"
bash /tmp/fallow-scripts/review.sh || echo "WARNING: MR review failed"
else
echo "WARNING: review.sh not available — skipping MR review"
fi
fi
# ── Fail check ───────────────────────────────────────────────────
if [ "$FALLOW_FAIL_ON_ISSUES" = "true" ] && [ "$ISSUES" -gt 0 ]; then
case "$FALLOW_COMMAND" in
dead-code|check) echo "ERROR: Fallow found ${ISSUES} unused code issues" ;;
dupes) echo "ERROR: Fallow found ${ISSUES} clone groups" ;;
health) echo "ERROR: Fallow found ${ISSUES} health findings" ;;
fix) echo "ERROR: Fallow found ${ISSUES} fixable issues" ;;
"") echo "ERROR: Fallow found ${ISSUES} issues" ;;
esac
exit 1
fi
FALLOW_SCRIPT_EOF
chmod +x /tmp/fallow-run.sh
script:
- bash /tmp/fallow-run.sh
artifacts:
when: always
paths:
- fallow-results.json
reports:
codequality:
- gl-code-quality-report.json
expire_in: 30 days
rules:
- if: $CI_MERGE_REQUEST_IID
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH