# Vercatus citability check for GitLab merge requests.
#
# Include this file and extend the hidden job with the preview address of your review app:
#
#   include:
#     - local: ci/vercatus-check-preview.yml   # a copy of this file in your repository
#   vercatus:
#     extends: .vercatus-check-preview
#     needs: [deploy_review]
#     variables:
#       VERCATUS_PREVIEW_URL: $CI_ENVIRONMENT_URL          # or the review app's address
#       VERCATUS_SITE_ID: "<site id>"
#
# CI/CD variables (masked): VERCATUS_API_KEY, a Vercatus key with the read scope limited to this site;
# VERCATUS_GITLAB_TOKEN, a project access token with the api scope, to post one note on the merge request and update
# it on every push. Without it the result is in the job log and in the vercatus-check.md artifact.
# Retrieval is simulated in the job (BM25 and a local embedding model); no answer engine and no paid model is called.

.vercatus-check-preview:
  image: mcr.microsoft.com/playwright:v1.63.0-noble
  stage: test
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  variables:
    VERCATUS_API_URL: https://app.vercatus.com
    # SECURITY: "1" installs the newest 1.x release on every pipeline, in the job that holds VERCATUS_API_KEY (and any
    # preview-protection secret passed through VERCATUS_EXTRA_ARGS). Pin an exact version (for example "1.4.2") and raise it
    # by merge request. The default is left as it is so existing pipelines keep working.
    VERCATUS_CLI_VERSION: "1"
    VERCATUS_PATHS: ""                 # comma-separated; empty takes the pages the changed files touch
    VERCATUS_ROUTE_MAP: ""
    VERCATUS_MAX_LOST: "0"
    VERCATUS_EXTRA_ARGS: ""            # e.g. --no-fail-on-body-loss --preview-query "x-bypass=..."
    MODEL_DIR: $CI_PROJECT_DIR/.cache/vercatus/models
    GIT_DEPTH: "0"
  cache:
    key: vercatus-embedding-multilingual-e5-small-v1
    paths: [.cache/vercatus/models]
  artifacts:
    when: always
    paths: [vercatus-check.md, vercatus-check.json]
  script:
    - npm install --no-audit --no-fund --prefix /tmp/vercatus "@vercatus/cli@${VERCATUS_CLI_VERSION}"
    - git fetch --no-tags origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
    - |
      set +e
      PATHS_ARG="git:origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"; [ -n "$VERCATUS_PATHS" ] && PATHS_ARG="$VERCATUS_PATHS"
      ROUTE_ARGS=""; [ -n "$VERCATUS_ROUTE_MAP" ] && ROUTE_ARGS="--route-map $VERCATUS_ROUTE_MAP"
      /tmp/vercatus/node_modules/.bin/vercatus check-preview "$VERCATUS_PREVIEW_URL" --site "$VERCATUS_SITE_ID" --paths "$PATHS_ARG" \
        --max-lost "$VERCATUS_MAX_LOST" --markdown vercatus-check.md --json vercatus-check.json $ROUTE_ARGS $VERCATUS_EXTRA_ARGS > /dev/null
      CODE=$?
      set -e
      [ -s vercatus-check.md ] || printf '<!-- vercatus-check-preview -->\n### Vercatus citability check: could not run\n\nThe check stopped before comparing; the job log says why.\n' > vercatus-check.md
      cat vercatus-check.md
      if [ -n "$VERCATUS_GITLAB_TOKEN" ]; then
        # one note per merge request: every page of the notes is read (not the newest hundred), the token's own marked notes are
        # found by the marker, the oldest is updated and any other the token left (two pipelines that started together) is removed
        node -e '
          const fs = require("fs");
          const base = process.env.CI_API_V4_URL;
          const api = `${base}/projects/${process.env.CI_PROJECT_ID}/merge_requests/${process.env.CI_MERGE_REQUEST_IID}/notes`;
          const headers = { "PRIVATE-TOKEN": process.env.VERCATUS_GITLAB_TOKEN, "content-type": "application/json" };
          const MARK = "<!-- vercatus-check-preview -->";
          const raw = fs.readFileSync("vercatus-check.md", "utf8");
          const body = raw.startsWith(MARK) ? raw : `${MARK}\n${raw}`;
          const wait = (ms) => new Promise((r) => setTimeout(r, ms));
          const call = async (url, init = {}) => {
            for (let i = 0; i < 4; i++) {
              try { const r = await fetch(url, { ...init, headers, signal: AbortSignal.timeout(20000) }); if (r.status !== 429 && r.status < 500) return r; if (i === 3) return r; }
              catch (e) { if (i === 3) throw e; }
              await wait(1000 * 2 ** i);
            }
          };
          (async () => {
            const me = await call(`${base}/user`).then((r) => (r.ok ? r.json() : null)).catch(() => null);
            const mine = [];
            for (let page = 1; page && page < 200;) {
              const r = await call(`${api}?per_page=100&sort=asc&order_by=created_at&page=${page}`);
              if (!r.ok) break;
              for (const n of await r.json()) if (!n.system && typeof n.body === "string" && n.body.startsWith(MARK) && (!me || (n.author && n.author.id === me.id))) mine.push(n);
              page = Number(r.headers.get("x-next-page")) || 0;
            }
            const keep = mine[0];
            const r = await call(keep ? `${api}/${keep.id}` : api, { method: keep ? "PUT" : "POST", body: JSON.stringify({ body }) });
            if (!r.ok) { console.error(`note: ${r.status} ${await r.text()}`); return; }
            if (me) for (const extra of mine.slice(1)) await call(`${api}/${extra.id}`, { method: "DELETE" }).catch(() => {});
          })().catch((e) => console.error(`note: ${e.message}`)).finally(() => process.exit(0));'
      fi
      exit $CODE
