Files
santifer--career-ops/set-status.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

344 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* set-status.mjs — canonical CLI to update a tracker row's status/note (#1428).
*
* data/applications.md is a shared surface with multiple readers and writers.
* One canonical write path is safer than N agents hand-editing markdown, so
* modes (apply Step 9, followup, batch) call this instead of editing the table.
*
* Usage:
* node set-status.mjs <report#|company> <state> [--note "..."] [--role "..."] [--dry-run] [--json]
*
* Row resolution:
* - numeric argument → exact match on the # column; if the tracker has a
* duplicate # (see #1704 — merge-tracker.mjs bug, now fixed, that could
* assign the same # to two rows), --role narrows it, otherwise it fails
* ambiguous with a candidate list instead of silently editing whichever
* row was found first
* - otherwise → company match (normalized, same key as merge-tracker dedup);
* multiple hits are narrowed with --role (fuzzy, role-matcher.mjs), and
* anything still ambiguous fails with a numbered candidate list.
*
* State validation is strict against templates/states.yml (labels, ids, and
* aliases resolve to the canonical label; anything else is rejected before the
* tracker is touched). --note appends to the Notes cell with "; " and is
* idempotent — re-running the same command is always safe.
*
* The read-modify-write runs under the shared tracker lock (tracker-utils.mjs,
* same lock as merge-tracker.mjs) and the file is replaced atomically. Only the
* Status and Notes cells of the matched row change; every other byte of the
* tracker round-trips untouched.
*
* Exit codes: 0 success (including no-op re-runs) · 1 usage error,
* non-canonical state, unreadable states.yml, or non-retryable lock/write failure ·
* 2 row not found or unreadable tracker · 3 ambiguous company match ·
* 4 tracker lock timeout (busy — retry later).
*
* When the new status is Applied, the JSON output carries
* `"followupSeedCandidate": true` — the hook point for seeding
* data/follow-ups.md with the default cadence (#1430, not implemented here).
*/
import { readFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { roleFuzzyMatch } from './role-matcher.mjs';
import {
rebuildRow, resolveTrackerPath, trackerLockDirFor, acquireTrackerLock,
writeFileAtomic, loadCanonicalStates, resolveCanonicalState, normalizeCompany, cell,
} from './tracker-utils.mjs';
const CAREER_OPS = dirname(fileURLToPath(import.meta.url));
const STATES_FILE = join(CAREER_OPS, 'templates/states.yml');
const EXIT_OK = 0;
const EXIT_USAGE = 1;
const EXIT_NOT_FOUND = 2;
const EXIT_AMBIGUOUS = 3;
const EXIT_LOCK_TIMEOUT = 4;
const USAGE = `Usage: node set-status.mjs <report#|company> <state> [--note "..."] [--role "..."] [--dry-run] [--json]
<report#|company> Row selector: tracker # (exact) or company name (normalized match)
<state> Canonical state from templates/states.yml (aliases accepted)
--note "..." Append to the Notes cell ("; "-separated, idempotent)
--role "..." Disambiguate when several rows share the company (fuzzy match)
--dry-run Resolve and validate, but write nothing
--json Machine-readable output on stdout (errors included)`;
// ── argument parsing ─────────────────────────────────────────────
const rawArgs = process.argv.slice(2);
const positional = [];
const flags = { note: null, role: null, dryRun: false, json: false };
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === '--note' || a === '--role') {
// Never consume a following flag as the value: "--note --dry-run" would
// silently disable dry-run and turn a preview into a real write.
const value = rawArgs[i + 1];
if (value === undefined || value.startsWith('--')) {
failUsage(`Missing value for ${a}`);
}
flags[a === '--note' ? 'note' : 'role'] = value;
i++;
}
else if (a === '--dry-run') { flags.dryRun = true; }
else if (a === '--json') { flags.json = true; }
else if (a.startsWith('--')) { failUsage(`Unknown flag: ${a}`); }
else { positional.push(a); }
}
if (positional.length !== 2) {
failUsage(positional.length === 0 ? null : `Expected 2 arguments (selector, state), got ${positional.length}`);
}
const [selector, stateInput] = positional;
/**
* Emit a structured error and exit.
*
* With --json the error object goes to stdout so callers parse one stream; the
* human-readable message always goes to stderr.
*
* @param {number} exitCode - Process exit code (see EXIT_* contract above).
* @param {string} code - Stable machine-readable error code.
* @param {string} message - Human-readable explanation.
* @param {object} [extra] - Extra JSON fields (e.g. candidates).
* @returns {never}
*/
function failWith(exitCode, code, message, extra = {}) {
if (flags.json) {
console.log(JSON.stringify({ error: message, code, ...extra }));
}
console.error(`❌ ${message}`);
process.exit(exitCode);
}
/**
* Print usage (plus an optional specific complaint) and exit 1.
*
* With --json a structured usage-error payload goes to stdout (same shape as
* failWith) so machine callers always parse one stream. failUsage can fire
* mid-argv-parse — before flags.json is settled — so JSON mode is detected
* from the raw argv directly.
*
* @param {string|null} message - What was wrong with the invocation, if known.
* @returns {never}
*/
function failUsage(message) {
const msg = message ?? 'Expected 2 arguments: <report#|company> <state>';
if (rawArgs.includes('--json')) {
console.log(JSON.stringify({ error: msg, code: 'usage' }));
console.error(`❌ ${msg}`);
} else {
if (message) console.error(`❌ ${message}\n`);
console.error(USAGE);
}
process.exit(EXIT_USAGE);
}
// ── state validation (before anything touches the tracker) ──────
let states;
try {
states = loadCanonicalStates(STATES_FILE);
} catch (err) {
failWith(EXIT_USAGE, 'states-error', `Cannot load canonical states from ${STATES_FILE}: ${err.message}`);
}
const newStatus = resolveCanonicalState(stateInput, states);
if (!newStatus) {
const valid = states.map(s => s.label).join(' · ');
failWith(EXIT_USAGE, 'invalid-state', `"${stateInput}" is not a canonical state. Valid states: ${valid}`);
}
// ── tracker access ───────────────────────────────────────────────
const APPS_FILE = resolveTrackerPath(CAREER_OPS);
if (!existsSync(APPS_FILE)) {
failWith(EXIT_NOT_FOUND, 'no-tracker', `No tracker found at ${APPS_FILE}`);
}
/**
* Find the tracker row matching the CLI selector.
*
* @param {object[]} rows - Parsed data rows (parseTrackerRow output + lineIdx).
* @returns {object} The single matched row. Exits the process on 0 or 2+ matches.
*/
function resolveRow(rows) {
if (/^\d+$/.test(selector)) {
const num = parseInt(selector, 10);
let matches = rows.filter(r => r.num === num);
if (matches.length === 0) {
failWith(EXIT_NOT_FOUND, 'not-found', `No tracker row with #${num}`);
}
if (matches.length > 1 && flags.role) {
const narrowed = matches.filter(r => roleFuzzyMatch(r.role, flags.role));
if (narrowed.length === 1) return narrowed[0];
// Fall through with the original list so the candidates stay visible.
}
if (matches.length > 1) {
// A bare report number should never match more than one row — this is
// exactly the failure mode from #1704: a stale tracker # reused across
// 2+ rows means "the first match" is a silent coin flip on which
// company gets edited. Refuse to guess; require --role or the company
// selector instead.
const candidates = matches.map(r => ({ num: r.num, company: r.company, role: r.role }));
const listing = candidates.map(c => `#${c.num}\t${c.company}\t${c.role}`).join('\n');
failWith(EXIT_AMBIGUOUS, 'ambiguous',
`#${num} is a duplicate tracker number shared by ${matches.length} rows (see #1704) — pass --role to disambiguate, or use the company name instead:\n${listing}`,
{ candidates });
}
return matches[0];
}
const key = normalizeCompany(selector);
if (!key) failUsage(`Selector "${selector}" is empty after normalization`);
let matches = rows.filter(r => normalizeCompany(r.company) === key);
if (matches.length === 0) {
failWith(EXIT_NOT_FOUND, 'not-found', `No tracker row with company matching "${selector}"`);
}
if (matches.length > 1 && flags.role) {
const narrowed = matches.filter(r => roleFuzzyMatch(r.role, flags.role));
if (narrowed.length === 1) return narrowed[0];
// Fall through with the original list so the candidates stay visible.
}
if (matches.length > 1) {
const candidates = matches.map(r => ({ num: r.num, company: r.company, role: r.role }));
const listing = candidates.map(c => `#${c.num}\t${c.company}\t${c.role}`).join('\n');
failWith(EXIT_AMBIGUOUS, 'ambiguous',
`Company "${selector}" matches ${matches.length} rows — pass the # or narrow with --role:\n${listing}`,
{ candidates });
}
return matches[0];
}
// ── locked read-modify-write ─────────────────────────────────────
// Dry-run never writes, so it must not hold the exclusive lock: a read-only
// preview should not block (or be blocked by) merge-tracker or another
// set-status writer. A stale read is acceptable for a preview.
let lock = null;
if (!flags.dryRun) {
try {
lock = await acquireTrackerLock(trackerLockDirFor(APPS_FILE), {
timeoutMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_TIMEOUT_MS) || 60_000,
retryMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_RETRY_MS) || 75,
staleMs: Number(process.env.CAREER_OPS_TRACKER_LOCK_STALE_MS) || 10 * 60_000,
tracker: APPS_FILE,
});
} catch (err) {
// Exit 4 means "lock is busy — retry later" and must stay reserved for
// the actual timeout. Filesystem/configuration failures (EACCES on the
// lock dir, unwritable owner.json, …) are not retryable and fail as a
// config error instead.
if (err?.code === 'LOCK_TIMEOUT') {
failWith(EXIT_LOCK_TIMEOUT, 'lock-timeout', err.message);
}
failWith(EXIT_USAGE, 'lock-error', `Cannot acquire tracker lock: ${err.message}`);
}
}
// Safety net: failWith/failUsage/resolveRow call process.exit() directly and
// skip the explicit release below. release() is idempotent, so both firing
// on the happy path is fine.
if (lock) process.once('exit', () => lock.release());
let content;
try {
content = readFileSync(APPS_FILE, 'utf-8');
} catch (err) {
failWith(EXIT_NOT_FOUND, 'read-failure', `Cannot read tracker at ${APPS_FILE}: ${err.message}`);
}
const lines = content.split('\n');
const colmap = resolveColumns(lines);
const rows = [];
for (let i = 0; i < lines.length; i++) {
const row = parseTrackerRow(lines[i], colmap);
if (row) rows.push({ ...row, lineIdx: i });
}
if (rows.length === 0) {
failWith(EXIT_NOT_FOUND, 'empty-tracker', `Tracker at ${APPS_FILE} has no data rows`);
}
const target = resolveRow(rows);
const oldStatus = target.status;
const note = flags.note != null ? cell(flags.note) : null;
// Rebuild only the matched line: change the Status cell, append the note, keep
// every other cell exactly as parsed.
const parts = lines[target.lineIdx].split('|').map(s => s.trim());
while (parts.length <= Math.max(colmap.status, colmap.notes ?? 0)) parts.push('');
const statusChanged = parts[colmap.status] !== newStatus;
parts[colmap.status] = newStatus;
let noteChanged = false;
if (note) {
if (colmap.notes == null) {
failWith(EXIT_USAGE, 'no-notes-column', 'Tracker has no Notes column — cannot apply --note');
}
const existing = parts[colmap.notes] ?? '';
// Delimiter-aware idempotency: the note counts as already present only when
// it appears as a whole "; "-delimited entry (or as the entire field) — a
// bare substring of a longer entry ("sent" inside "sent CV") must not
// suppress a genuinely new note. Matching the full note text at entry
// boundaries (instead of splitting the field into segments) keeps retries
// idempotent even when the note itself contains "; ".
const hasNote = existing === note
|| existing.startsWith(`${note}; `)
|| existing.endsWith(`; ${note}`)
|| existing.includes(`; ${note}; `);
if (!hasNote) {
parts[colmap.notes] = existing && existing !== '—' && existing !== '-' ? `${existing}; ${note}` : note;
noteChanged = true;
}
}
const changed = statusChanged || noteChanged;
if (changed && !flags.dryRun) {
lines[target.lineIdx] = rebuildRow(parts);
try {
writeFileAtomic(APPS_FILE, lines.join('\n'));
} catch (err) {
// Same structured error contract as every other failure path — a raw
// stack trace on stdout/stderr would break --json consumers.
failWith(EXIT_USAGE, 'write-failure', `Cannot write tracker at ${APPS_FILE}: ${err.message}`);
}
}
lock?.release();
// ── report ───────────────────────────────────────────────────────
const result = {
changed,
num: target.num,
company: target.company,
role: target.role,
oldStatus,
newStatus,
...(note != null ? { note } : {}),
...(flags.dryRun ? { dryRun: true } : {}),
// Fire the #1430 hook only on an actual transition INTO Applied — an
// idempotent re-run of an already-Applied row must not invite a consumer
// to seed a duplicate follow-up.
...(statusChanged && newStatus === 'Applied' ? { followupSeedCandidate: true } : {}),
tracker: APPS_FILE,
};
if (flags.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const verb = flags.dryRun ? 'would set' : changed ? 'set' : 'already';
console.log(`✅ #${target.num} ${target.company}${target.role}: ${verb} ${oldStatus}${newStatus}${note ? ` (note: ${note})` : ''}`);
if (statusChanged && !flags.dryRun && newStatus === 'Applied') {
console.error('️ Status is Applied — consider seeding follow-ups in data/follow-ups.md (#1430: node followup-cadence.mjs)');
}
}
process.exit(EXIT_OK);