name: "Cite Files — AI citation readiness"
description: >-
  Check whether AI assistants can reach, read and cite your site, and fail the
  build when it gets worse.
author: "Cite Files"
branding:
  icon: "file-text"
  color: "yellow"

inputs:
  url:
    description: "The public site to check. Must be reachable from the internet."
    required: true
  min-score:
    description: >-
      Fail if the citation-readiness score is below this. Leave unset to report
      without failing.
    required: false
  fail-on:
    description: >-
      Fail when findings of this severity or worse are present: critical, major,
      or none. Defaults to none.
    required: false
    default: "none"
  api:
    description: "Override the Cite Files endpoint. You will not normally need this."
    required: false
    default: "https://citefiles.com"

outputs:
  score:
    description: "Citation-readiness score out of 100, or empty when not measurable."
    value: ${{ steps.check.outputs.score }}
  grade:
    description: "Letter grade."
    value: ${{ steps.check.outputs.grade }}
  report-url:
    description: "Link to the full report."
    value: ${{ steps.check.outputs.report-url }}

runs:
  using: "composite"
  steps:
    - id: check
      shell: bash
      env:
        CF_URL: ${{ inputs.url }}
        CF_MIN: ${{ inputs.min-score }}
        CF_FAIL_ON: ${{ inputs.fail-on }}
        CF_API: ${{ inputs.api }}
      run: |
        set -uo pipefail

        # The MCP endpoint is the public, account-free surface, so the action
        # needs no secret. That is deliberate: a CI check that requires a token
        # is a check people do not add.
        body=$(printf '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"citefiles_scan","arguments":{"url":"%s"}}}' "$CF_URL")
        raw=$(curl -sS --max-time 300 -H 'Content-Type: application/json' -d "$body" "$CF_API/mcp") || {
          echo "::warning::Cite Files could not be reached. Not failing the build for our outage."
          exit 0
        }

        payload=$(printf '%s' "$raw" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("result",{}).get("content",[{}])[0].get("text",""))')
        if [ -z "$payload" ]; then
          echo "::warning::Cite Files returned nothing readable. Not failing the build."
          exit 0
        fi
        if printf '%s' "$payload" | grep -q '^error:'; then
          echo "::warning::$payload"
          exit 0
        fi

        python3 - "$payload" <<'PY'
        import json, os, sys

        d = json.loads(sys.argv[1])
        out = os.environ["GITHUB_OUTPUT"]
        summary = os.environ.get("GITHUB_STEP_SUMMARY", os.devnull)

        score = d.get("score")
        grade = d.get("grade") or ""
        issues = d.get("issues") or []
        crit = [i for i in issues if i["severity"] == "critical"]
        major = [i for i in issues if i["severity"] == "major"]

        with open(out, "a") as f:
            f.write(f"score={score if score is not None else ''}\n")
            f.write(f"grade={grade}\n")
            f.write(f"report-url={d.get('reportUrl','')}\n")

        with open(summary, "a") as f:
            f.write(f"## Cite Files — {d.get('host','')}\n\n")
            if d.get("measurable"):
                f.write(f"**{score}/100** (grade {grade})\n\n")
            else:
                # An unreadable site is not a zero. Saying so matters: a build
                # that fails on "score 0" when the real answer is "we could not
                # read it" sends people to fix the wrong thing.
                f.write("Not measurable — the site could not be read as a browser, so no score was produced.\n\n")
            for i in issues[:20]:
                f.write(f"- **{i['severity']}** ({i['code']}) {i['message']}\n")
            f.write(f"\n[Full report]({d.get('reportUrl','')})\n")

        fail_on = os.environ.get("CF_FAIL_ON", "none")
        problems = []
        if fail_on == "critical" and crit:
            problems.append(f"{len(crit)} critical finding(s)")
        if fail_on == "major" and (crit or major):
            problems.append(f"{len(crit)} critical and {len(major)} major finding(s)")

        min_score = os.environ.get("CF_MIN", "").strip()
        if min_score and d.get("measurable") and score is not None and score < int(min_score):
            problems.append(f"score {score} is below the required {min_score}")

        for i in crit:
            print(f"::error title=Cite Files ({i['code']})::{i['message']}")
        for i in major:
            print(f"::warning title=Cite Files ({i['code']})::{i['message']}")

        if problems:
            print("::error::Cite Files check failed: " + "; ".join(problems))
            sys.exit(1)
        PY
