#!/usr/bin/env node /** * evidence-freshness.mjs — is the GitHub evidence behind an evaluation stale? * * `oferta`, `auto-pipeline` and batch workers score a JD against cv.md, * article-digest.md and the profile. GitHub activity reaches those files only * when the user runs `expand`, whose optional step (github-evidence.mjs) writes * output/github-evidence-{YYYY-MM-DD}.{md,json}. Nothing said how old that scan * was, so PRs merged after it were silently missing from the fit and a score * could understate it with no warning. * * Opt-in: off unless config/profile.yml sets evidence_freshness.enabled: true, * so nobody gets a GitHub call they did not ask for. Interactive evaluations run * it once before Block A; batch-runner.sh runs it once per batch run and hands * the result to every worker. When on, it reports: * - the newest output/github-evidence-*.json and how many days old it is * - how many PRs the user authored and merged since then in repos they do not * own (owning a repo is not authorship, so github-evidence.mjs never counts * those either), from ONE metadata-only `gh search prs` call through the * user's existing gh auth (no new token, no repo names or titles kept) * - a stale verdict against thresholds in config/profile.yml * * Read-only and never blocking: it writes nothing, never runs expand or * github-evidence.mjs, and exits 0 with JSON on stdout whatever happens. When * candidate.github is unset, gh is missing or logged out, or the query fails, * the status says so and `stale` is false. * * Config (config/profile.yml, all optional): * evidence_freshness: * enabled: true # off unless set to true * min_new_merged_prs: 5 # stale at this many merged PRs since the last scan * max_age_days: 30 # stale when the last scan is older than this * With no scan on record, the evidence is stale as soon as the user has one * merged PR in someone else's repo. * * Usage: * node evidence-freshness.mjs # JSON on stdout, always exit 0 */ import { spawnSync } from 'child_process'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import { loadYaml } from './lib/yaml-document.mjs'; import { getCareerOpsRoot, getProfilePath, hasProfileOverride } from './path-resolver.mjs'; import { localToday } from './lib/local-today.mjs'; import { isMainModule } from './lib/is-main-module.mjs'; import { validateFlags } from './lib/cli-flags.mjs'; import { normalizeGithubUser } from './github-evidence.mjs'; const KNOWN_FLAGS = ['--help', '-h']; const USAGE = `Usage: node evidence-freshness.mjs # JSON on stdout, always exit 0 Says whether the newest output/github-evidence-*.json is stale: its age, and the PRs you authored and merged since then in repos you do not own (one metadata-only gh search through your existing gh auth). Read-only. Opt-in: set config/profile.yml -> evidence_freshness.enabled: true.`; export const DEFAULTS = Object.freeze({ enabled: false, minNewMergedPrs: 5, maxAgeDays: 30 }); /** One search page: the count is exact below this and shown as "100+" at it. */ export const QUERY_LIMIT = 100; const SCAN_FILE_RE = /^github-evidence-(\d{4})-(\d{2})-(\d{2})\.json$/; const DAY_MS = 24 * 60 * 60 * 1000; /** * `evidence_freshness` from config/profile.yml, with the defaults filled in. * The check is on only when `enabled` is exactly true. A threshold that is not a * whole number of at least 1 keeps its default and is reported, so a typo never * switches a rule off. * * @param {object|null} profile - Parsed config/profile.yml. * @returns {{thresholds: {enabled: boolean, minNewMergedPrs: number, maxAgeDays: number}, warnings: string[]}} */ export function readThresholds(profile) { const thresholds = { ...DEFAULTS }; const warnings = []; const raw = profile?.evidence_freshness; if (raw == null) return { thresholds, warnings }; if (typeof raw !== 'object' || Array.isArray(raw)) { warnings.push('evidence_freshness is not a map; using the defaults'); return { thresholds, warnings }; } if (raw.enabled === true) thresholds.enabled = true; else if (raw.enabled != null && raw.enabled !== false) warnings.push('evidence_freshness.enabled is not true or false; the check stays off'); for (const [key, prop] of [['min_new_merged_prs', 'minNewMergedPrs'], ['max_age_days', 'maxAgeDays']]) { if (raw[key] == null) continue; if (Number.isInteger(raw[key]) && raw[key] >= 1) thresholds[prop] = raw[key]; else warnings.push(`evidence_freshness.${key} must be a whole number of at least 1; using ${DEFAULTS[prop]}`); } return { thresholds, warnings }; } /** * The newest github-evidence scan for this user, or null when there is none. * * Files are taken newest date first. One that does not parse as a JSON object, * or that records a different `user`, is skipped: it is not a scan of this * account. `since` is the lower bound for "merged after the scan": the file's * modification time when it falls on the file's own date (the time the scan was * written), otherwise the start of that local day, so PRs merged earlier on the * scan day are not counted as new right after a scan. * * @param {string} dataRoot * @param {string} user - Normalized GitHub username. * @returns {{date: string, file: string, since: string}|null} */ export function findLatestScan(dataRoot, user) { const dir = join(dataRoot, 'output'); if (!existsSync(dir)) return null; const candidates = readdirSync(dir) .map((name) => ({ name, m: name.match(SCAN_FILE_RE) })) .filter(({ m }) => m && isCalendarDate(+m[1], +m[2], +m[3])) .sort((a, b) => b.name.localeCompare(a.name)); for (const { name, m } of candidates) { const path = join(dir, name); let doc; try { doc = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } if (!doc || typeof doc !== 'object' || Array.isArray(doc)) continue; if (typeof doc.user === 'string' && doc.user.toLowerCase() !== user.toLowerCase()) continue; const date = `${m[1]}-${m[2]}-${m[3]}`; let since = isoSeconds(new Date(+m[1], +m[2] - 1, +m[3])); try { const mtime = statSync(path).mtime; if (localToday(mtime) === date) since = isoSeconds(mtime); } catch { // Unreadable metadata: keep the start of the scan's day. } return { date, file: `output/${name}`, since }; } return null; } // GitHub search takes `YYYY-MM-DDTHH:MM:SSZ`; milliseconds are dropped (rounding down). const isoSeconds = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z'); function isCalendarDate(y, m, d) { const t = new Date(Date.UTC(y, m - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d; } /** Whole days from `from` to `to` (both YYYY-MM-DD), never negative. */ export function daysBetween(from, to) { const utc = (s) => Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)); return Math.max(0, Math.round((utc(to) - utc(from)) / DAY_MS)); } /** * The single gh call: PRs the user authored that were merged (after `since`, * when there is a scan), excluding repos the user owns. One search page, so one * request; only the repository field is asked for, and only the count is kept. */ export function searchArgs(user, since) { const args = ['search', 'prs', '--author', user, '--merged']; if (since) args.push('--merged-at', `>=${since}`); args.push('--limit', String(QUERY_LIMIT), '--json', 'repository', '--', `-user:${user}`); return args; } /** * The real gh runner. Injectable, so tests never spawn gh or reach the network. * Bounded, so a hung gh never holds up an evaluation. */ export function defaultGhRunner(args) { const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 30_000 }); return { status: res.status, signal: res.signal ?? null, stdout: res.stdout || '', stderr: res.stderr || '', notFound: res.error?.code === 'ENOENT', }; } // gh exits 4 when it has no credentials; a bad or expired token comes back as a 401. const AUTH_FAILURE = /auth login|not logged|bad credentials|401 unauthorized|HTTP 401|authentication/i; const firstLine = (s) => String(s ?? '').trim().split('\n')[0]; /** * Run the search and classify the outcome. * * @returns {{ok: true, count: number, truncated: boolean} * | {ok: false, status: 'gh-unavailable'|'gh-query-failed', detail: string}} */ export function countNewMergedPrs(user, since, runGh) { const res = runGh(searchArgs(user, since)); if (res.notFound) { return { ok: false, status: 'gh-unavailable', detail: 'gh is not installed (not found on PATH)' }; } if (res.status !== 0) { if (res.status === 4 || AUTH_FAILURE.test(res.stderr)) { return { ok: false, status: 'gh-unavailable', detail: 'gh is installed but not logged in to github.com' }; } const detail = firstLine(res.stderr) || (res.signal ? `killed by ${res.signal}` : `exit ${res.status}`); return { ok: false, status: 'gh-query-failed', detail: `gh search prs failed: ${detail}` }; } let rows; try { rows = JSON.parse(res.stdout); } catch { return { ok: false, status: 'gh-query-failed', detail: 'gh search prs returned output that is not JSON' }; } if (!Array.isArray(rows)) return { ok: false, status: 'gh-query-failed', detail: 'gh search prs did not return a list' }; // The query already excludes the user's repos; this re-checks it. const own = `${user.toLowerCase()}/`; const count = rows.filter((r) => !String(r?.repository?.nameWithOwner ?? '').toLowerCase().startsWith(own)).length; return { ok: true, count, truncated: rows.length >= QUERY_LIMIT }; } const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; const ago = (days) => (days === 0 ? 'today' : `${plural(days, 'day')} ago`); /** * Pure verdict. * * @param {object} input * @param {string|null} input.lastScan - YYYY-MM-DD, or null when no scan is on record. * @param {number|null} input.daysSince * @param {number} input.newMergedPrs * @param {boolean} input.truncated - The count reached QUERY_LIMIT. * @param {{minNewMergedPrs: number, maxAgeDays: number}} input.thresholds * @returns {{stale: boolean, reason: string, notice: string|null}} */ export function judge({ lastScan, daysSince, newMergedPrs, truncated, thresholds }) { const count = truncated ? `${QUERY_LIMIT}+` : String(newMergedPrs); const prs = `${count} merged PR${count === '1' ? '' : 's'}`; if (!lastScan) { if (newMergedPrs === 0) { return { stale: false, reason: 'no GitHub evidence scan on record, and no merged PRs in other people\'s repos for one to find', notice: null }; } return { stale: true, reason: `no GitHub evidence scan on record, and ${prs} in other people's repos`, notice: `No GitHub evidence scan on record; ${prs} in other people's repos. Scores may understate fit.`, }; } const byCount = truncated || newMergedPrs >= thresholds.minNewMergedPrs; const byAge = daysSince > thresholds.maxAgeDays; const parts = [ `${prs} since the last scan (stale at ${thresholds.minNewMergedPrs})`, `last scan ${ago(daysSince)} (stale after ${plural(thresholds.maxAgeDays, 'day')})`, ]; if (!byCount && !byAge) return { stale: false, reason: `within thresholds: ${parts.join('; ')}`, notice: null }; return { stale: true, reason: [byCount && parts[0], byAge && parts[1]].filter(Boolean).join('; '), notice: `Last GitHub evidence scan: ${lastScan} (${ago(daysSince)}); ${prs} since. Scores may understate fit.`, }; } // ── CLI ───────────────────────────────────────────────────────────── /** * The CLI body, every outside effect injectable for tests. * * @returns {number} Always 0: this check informs an evaluation, it never stops one. */ export function runCli({ dataRoot = getCareerOpsRoot(), runGh = defaultGhRunner, profilePath = hasProfileOverride() ? getProfilePath() : getProfilePath(dataRoot), now = new Date(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), } = {}) { // Every status prints the same keys in the same order; a field this run could // not fill is null (false for the two flags). const base = { status: null, stale: false, reason: null, notice: null, user: null, lastScan: null, scanFile: null, daysSince: null, newMergedPrs: null, newMergedPrsTruncated: false, }; let thresholds = { ...DEFAULTS }; const emit = (fields) => { const { minNewMergedPrs, maxAgeDays } = thresholds; stdout(`${JSON.stringify({ ...base, ...fields, thresholds: { minNewMergedPrs, maxAgeDays } }, null, 2)}\n`); return 0; }; if (!existsSync(profilePath)) return emit({ status: 'no-github', reason: 'config/profile.yml not found' }); let profile; try { profile = loadYaml(readFileSync(profilePath, 'utf8')) || {}; } catch (err) { return emit({ status: 'no-github', reason: `config/profile.yml is not valid YAML: ${firstLine(err.message)}` }); } const read = readThresholds(profile); thresholds = read.thresholds; for (const w of read.warnings) stderr(`evidence-freshness: ${w}\n`); if (!thresholds.enabled) return emit({ status: 'disabled', reason: 'the check is opt-in and evidence_freshness.enabled is not true in config/profile.yml' }); const user = normalizeGithubUser(profile?.candidate?.github); if (!user) { return emit({ status: 'no-github', reason: 'config/profile.yml -> candidate.github is not set to a GitHub username or profile link' }); } const today = localToday(now); const scan = findLatestScan(dataRoot, user); const scanFields = { user, lastScan: scan?.date ?? null, scanFile: scan?.file ?? null, daysSince: scan ? daysBetween(scan.date, today) : null, }; const found = countNewMergedPrs(user, scan?.since ?? null, runGh); if (!found.ok) return emit({ ...scanFields, status: found.status, reason: `${found.detail}; freshness not checked` }); const verdict = judge({ lastScan: scanFields.lastScan, daysSince: scanFields.daysSince, newMergedPrs: found.count, truncated: found.truncated, thresholds, }); return emit({ status: 'ok', ...scanFields, newMergedPrs: found.count, newMergedPrsTruncated: found.truncated, ...verdict, }); } if (isMainModule(import.meta.url)) { validateFlags(process.argv.slice(2), KNOWN_FLAGS, USAGE); process.exitCode = runCli(); }