Source: integrations/githubChecks.js

/**
 * @module integrations/githubChecks
 * @description GitHub App Check Run client with cached installation tokens (INT-002).
 */

import crypto from "node:crypto";
import { signJwt, verifyJwt, getJwtSecret } from "../middleware/authenticate.js";
import { redis, isRedisAvailable } from "../utils/redisClient.js";
import { formatLogLine } from "../utils/logFormatter.js";

const CHECK_NAME = process.env.GITHUB_CHECK_NAME || "Sentri QA";
const TOKEN_REFRESH_SKEW_MS = 60_000;
const tokenCache = new Map();
const installStateCache = new Map();
const INSTALL_STATE_TTL_SEC = 600;
let inMemoryNonceWarned = false;

function base64url(input) {
  return Buffer.from(input).toString("base64url");
}

function getPrivateKey() {
  const key = process.env.GITHUB_APP_PRIVATE_KEY || "";
  return key.includes("\\n") ? key.replace(/\\n/g, "\n") : key;
}

function createAppJwt(now = Math.floor(Date.now() / 1000)) {
  const appId = process.env.GITHUB_APP_ID;
  const privateKey = getPrivateKey();
  if (!appId || !privateKey) throw new Error("GitHub App credentials are not configured");
  const header = { alg: "RS256", typ: "JWT" };
  const payload = { iat: now - 60, exp: now + 9 * 60, iss: appId };
  const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`;
  const signature = crypto.sign("RSA-SHA256", Buffer.from(signingInput), privateKey).toString("base64url");
  return `${signingInput}.${signature}`;
}

// Retry policy for transient upstream errors. GitHub's API returns:
//   - 502 / 503 / 504 on edge-network issues (rare, ~seconds)
//   - 429 on secondary rate-limit (tens of seconds; honour Retry-After)
//   - 403 with `x-ratelimit-remaining: 0` on primary rate-limit
// A non-retried single 5xx would lose the check-run entirely on a busy CI
// fleet — industry-standard QA gates retry the GitHub API with bounded
// exponential backoff. We cap at 3 attempts and 4s total so the trigger
// path (which awaits this) doesn't block the run for long.
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
const MAX_ATTEMPTS = 3;
const BASE_BACKOFF_MS = 250;

function retryAfterMs(res) {
  const header = res.headers?.get?.("retry-after");
  if (!header) return null;
  const seconds = Number(header);
  if (Number.isFinite(seconds)) return Math.min(seconds * 1000, 4000);
  const dateMs = Date.parse(header);
  if (Number.isFinite(dateMs)) return Math.max(0, Math.min(dateMs - Date.now(), 4000));
  return null;
}

async function githubFetch(path, { method = "GET", token, body, fetchImpl = fetch } = {}) {
  let lastErr;
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    const apiBase = process.env.GITHUB_API_BASE || "https://api.github.com";
    const res = await fetchImpl(`${apiBase}${path}`, {
      method,
      headers: {
        "Accept": "application/vnd.github+json",
        "Content-Type": "application/json",
        "User-Agent": "sentri-github-checks",
        "X-GitHub-Api-Version": "2022-11-28",
        "Authorization": `Bearer ${token}`,
      },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (res.ok) return res.json();

    const text = await res.text().catch(() => "");
    lastErr = new Error(`GitHub API ${res.status}: ${text || res.statusText}`);
    lastErr.status = res.status;

    if (!RETRYABLE_STATUS.has(res.status) || attempt === MAX_ATTEMPTS) throw lastErr;
    const wait = retryAfterMs(res) ?? Math.min(BASE_BACKOFF_MS * 2 ** (attempt - 1), 2000);
    await new Promise((r) => setTimeout(r, wait));
  }
  throw lastErr;
}

/**
 * Clear cached installation tokens. Intended for tests.
 * @returns {void}
 */
export function clearInstallationTokenCache() {
  tokenCache.clear();
}

/**
 * Clear cached one-shot GitHub install state nonces. Intended for tests.
 * @returns {void}
 */
export function clearInstallStateCache() {
  installStateCache.clear();
  inMemoryNonceWarned = false;
}

/**
 * Return a cached GitHub App installation token, refreshing before expiry.
 *
 * @param {string|number} installationId
 * @param {Object} [options]
 * @param {Function} [options.fetchImpl]
 * @returns {Promise<string>}
 */
export async function getInstallationToken(installationId, { fetchImpl = fetch } = {}) {
  if (!installationId) throw new Error("GitHub installationId is required");
  const cacheKey = String(installationId);
  const cached = tokenCache.get(cacheKey);
  if (cached && cached.expiresAtMs - TOKEN_REFRESH_SKEW_MS > Date.now()) return cached.token;

  const jwt = createAppJwt();
  const data = await githubFetch(`/app/installations/${encodeURIComponent(cacheKey)}/access_tokens`, {
    method: "POST",
    token: jwt,
    fetchImpl,
  });
  const expiresAtMs = Date.parse(data.expires_at || "");
  tokenCache.set(cacheKey, {
    token: data.token,
    expiresAtMs: Number.isFinite(expiresAtMs) ? expiresAtMs : Date.now() + 50 * 60_000,
  });
  return data.token;
}


function installStateKey(nonce) {
  return `github-install-state:${nonce}`;
}

async function storeInstallNonce(nonce, ttlSec) {
  if (isRedisAvailable() && redis) {
    await redis.set(installStateKey(nonce), "1", "EX", ttlSec, "NX");
    return;
  }
  // In-memory fallback is process-local: in a multi-replica deploy without
  // Redis, an attacker who captured a redirect URL could potentially replay
  // it against a different replica that has never seen the nonce. Warn once
  // so operators notice in production logs; single-replica / dev setups are
  // safe. Industry-standard fix is to provision Redis (see REDIS_URL).
  if (!inMemoryNonceWarned) {
    inMemoryNonceWarned = true;
    console.warn(formatLogLine(
      "warn",
      "",
      "[github-install] Redis unavailable; install-state replay protection is process-local. "
        + "Set REDIS_URL for multi-replica deployments — see docs/api/projects.md § GitHub App Integration.",
    ));
  }
  installStateCache.set(nonce, Date.now() + ttlSec * 1000);
}

async function claimInstallNonce(nonce) {
  if (!nonce) return false;
  if (isRedisAvailable() && redis) {
    const removed = await redis.del(installStateKey(nonce));
    return removed === 1;
  }
  const expiresAt = installStateCache.get(nonce);
  installStateCache.delete(nonce);
  return Number.isFinite(expiresAt) && expiresAt > Date.now();
}

async function peekInstallNonce(nonce) {
  if (!nonce) return false;
  if (isRedisAvailable() && redis) {
    const exists = await redis.exists(installStateKey(nonce));
    return exists === 1;
  }
  const expiresAt = installStateCache.get(nonce);
  return Number.isFinite(expiresAt) && expiresAt > Date.now();
}

/**
 * Sign a short-lived, one-shot state token for the GitHub App install flow.
 *
 * The state JWT is the only auth on the callback route because GitHub
 * redirects the user back from `github.com` — a cross-site navigation that
 * browsers refuse to attach `SameSite=Strict` cookies to (the default for
 * same-origin Sentri deployments). The signed nonce-tracked state proves
 * an authenticated admin initiated the flow; the embedded `actor` fields
 * let the callback log who completed the install without `req.authUser`.
 *
 * @param {string} projectId
 * @param {Object} [options]
 * @param {number} [options.ttlSec=600]
 * @param {Object} [options.actor]
 *   Optional authenticated user metadata captured at sign-time so the
 *   callback can attribute the activity log entry.
 * @param {string} [options.actor.userId]
 * @param {string} [options.actor.userName]
 * @returns {Promise<string>} Signed state JWT.
 */
export async function signInstallState(projectId, { ttlSec = INSTALL_STATE_TTL_SEC, actor = {} } = {}) {
  if (!projectId) throw new Error("projectId is required");
  const nonce = crypto.randomUUID();
  await storeInstallNonce(nonce, ttlSec);
  return signJwt(
    {
      projectId,
      nonce,
      purpose: "github-install",
      actorId: actor.userId || null,
      actorName: actor.userName || null,
    },
    getJwtSecret(),
    ttlSec,
  );
}

/**
 * Verify a GitHub App install state token.
 *
 * By default the nonce is claimed (consumed) on verification — the token is
 * one-shot. Callers that need to perform fallible work (e.g. GitHub API
 * calls) between verification and final acceptance can pass `{ claim: false }`
 * to defer consumption and then call `claimInstallState(nonce)` after the
 * fallible work succeeds; this prevents a transient upstream failure from
 * permanently spending the state JWT and forcing the user to restart the
 * entire install flow.
 *
 * @param {string} token
 * @param {Object} [options]
 * @param {boolean} [options.claim=true] When false, the nonce is left in
 *   place; the caller is responsible for calling `claimInstallState(nonce)`.
 * @returns {Promise<{projectId: string, nonce: string, actorId: string|null, actorName: string|null}|null>}
 */
export async function verifyInstallState(token, { claim = true } = {}) {
  const payload = verifyJwt(token, getJwtSecret());
  if (!payload || payload.purpose !== "github-install" || !payload.projectId || !payload.nonce) return null;
  if (claim) {
    if (!await claimInstallNonce(payload.nonce)) return null;
  } else if (!await peekInstallNonce(payload.nonce)) {
    return null;
  }
  return {
    projectId: payload.projectId,
    nonce: payload.nonce,
    actorId: payload.actorId || null,
    actorName: payload.actorName || null,
  };
}

/**
 * Claim (consume) a previously-verified install-state nonce. Used by callers
 * that passed `{ claim: false }` to `verifyInstallState` and have now
 * completed their fallible work.
 *
 * @param {string} nonce
 * @returns {Promise<boolean>} True if the nonce was successfully claimed.
 */
export async function claimInstallState(nonce) {
  return claimInstallNonce(nonce);
}

function parseRepo(repo) {
  const [owner, name] = String(repo || "").split("/");
  if (!owner || !name) throw new Error("GitHub repo must be in owner/name format");
  return { owner, name };
}

/**
 * List repositories selected for a GitHub App installation.
 *
 * @param {string|number} installationId
 * @param {Object} [options]
 * @param {Function} [options.fetchImpl]
 * @returns {Promise<string[]>} Repository names in `owner/name` format.
 */
export async function getInstallationRepos(installationId, options = {}) {
  const token = await getInstallationToken(installationId, options);
  const fetchImpl = options.fetchImpl || fetch;
  const repos = [];
  let page = 1;
  while (page <= 10) {
    const data = await githubFetch(`/installation/repositories?per_page=100&page=${page}`, {
      token,
      fetchImpl,
    });
    const batch = Array.isArray(data?.repositories) ? data.repositories : [];
    repos.push(...batch.map((repo) => repo.full_name).filter(Boolean));
    if (batch.length < 100) break;
    page++;
  }
  return repos;
}

/**
 * List file paths changed by a pull request using the GitHub PR Files API.
 *
 * @param {Object} args
 * @param {string} args.repo
 * @param {number|string} args.prNumber
 * @param {string|number} args.installationId
 * @param {Object} [options]
 * @param {Function} [options.fetchImpl]
 * @returns {Promise<string[]>}
 */
export async function getChangedFilesForPr({ repo, prNumber, installationId }, options = {}) {
  const { owner, name } = parseRepo(repo);
  const n = Number(prNumber);
  if (!Number.isInteger(n) || n <= 0) throw new Error("GitHub prNumber must be a positive integer");
  const token = await getInstallationToken(installationId, options);
  const fetchImpl = options.fetchImpl || fetch;
  const files = [];
  let page = 1;
  while (page <= 10) {
    const data = await githubFetch(`/repos/${owner}/${name}/pulls/${n}/files?per_page=100&page=${page}`, {
      token,
      fetchImpl,
    });
    const batch = Array.isArray(data) ? data : [];
    files.push(...batch.map((f) => f?.filename).filter(Boolean));
    if (batch.length < 100) break;
    page++;
  }
  return [...new Set(files)];
}

/**
 * Create a queued GitHub Check Run.
 *
 * @param {string} runId
 * @param {Object} args
 * @param {string} args.repo
 * @param {string} args.sha
 * @param {string|number} args.installationId
 * @param {Object} [options]
 * @returns {Promise<Object>} GitHub check-run payload.
 */
export async function createPending(runId, { repo, sha, installationId }, options = {}) {
  const { owner, name } = parseRepo(repo);
  const token = await getInstallationToken(installationId, options);
  return githubFetch(`/repos/${owner}/${name}/check-runs`, {
    method: "POST",
    token,
    fetchImpl: options.fetchImpl || fetch,
    body: {
      name: CHECK_NAME,
      head_sha: sha,
      status: "queued",
      external_id: runId,
      details_url: buildRunUrl(runId),
    },
  });
}

/**
 * Mark an existing GitHub Check Run in progress.
 *
 * @param {string|number} checkRunId
 * @param {Object} args
 * @param {string} args.repo
 * @param {string|number} args.installationId
 * @param {Object} [options]
 * @returns {Promise<Object>}
 */
export async function markInProgress(checkRunId, { repo, installationId }, options = {}) {
  const { owner, name } = parseRepo(repo);
  const token = await getInstallationToken(installationId, options);
  return githubFetch(`/repos/${owner}/${name}/check-runs/${encodeURIComponent(checkRunId)}`, {
    method: "PATCH",
    token,
    fetchImpl: options.fetchImpl || fetch,
    body: { status: "in_progress", started_at: new Date().toISOString() },
  });
}

/**
 * Conclude an existing GitHub Check Run.
 *
 * @param {string|number} checkRunId
 * @param {Object} args
 * @param {string} args.repo
 * @param {string|number} args.installationId
 * @param {"success"|"failure"|"neutral"} args.conclusion
 * @param {string} args.summaryMd
 * @param {Object} [options]
 * @returns {Promise<Object>}
 */
export async function conclude(checkRunId, { repo, installationId, conclusion, summaryMd }, options = {}) {
  const { owner, name } = parseRepo(repo);
  const token = await getInstallationToken(installationId, options);
  return githubFetch(`/repos/${owner}/${name}/check-runs/${encodeURIComponent(checkRunId)}`, {
    method: "PATCH",
    token,
    fetchImpl: options.fetchImpl || fetch,
    body: {
      status: "completed",
      conclusion,
      completed_at: new Date().toISOString(),
      output: { title: "Sentri QA results", summary: summaryMd || "Sentri run completed." },
    },
  });
}

/**
 * Build an absolute Run Detail URL for GitHub's details link.
 *
 * @param {string} runId
 * @returns {string|undefined}
 */
export function buildRunUrl(runId) {
  const base = process.env.APP_URL || process.env.FRONTEND_URL || process.env.PUBLIC_APP_URL || "";
  if (!base) return undefined;
  return `${base.replace(/\/$/, "")}/runs/${encodeURIComponent(runId)}`;
}