init
Release Please / release-please (push) Failing after 4m2s
Web CI / web typecheck + build (push) Successful in 3m39s
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 1h1m49s
CodeQL Analysis / Analyze (go) (push) Failing after 1h1m49s

This commit is contained in:
redlineo committed 2026-07-13 10:14:00 +03:00
commit d295c7f139
1527 files changed
+270726

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
// @ts-check
// Notion API helper for the notion plugin. Ported from the notion-lib.mjs
// contributed by @pcomans in #959 (with thanks), reshaped for the plugin layer:
//
// - No module-level secrets / `import 'dotenv/config'`. The token + parent page
// id are passed into createNotionClient() from the plugin's scoped ctx.env,
// so nothing reads process.env at import time and the engine stays the place
// that decides when .env is loaded.
// - templates/states.yml (the canonical status source of truth) is resolved
// from the repo root, two levels up from this bundled plugin.
//
// Pages use Notion's native markdown on both sides. The 360ms inter-request
// sleep keeps us under ~3 req/s. DB resolution is by NAME so no workspace id is
// ever embedded. Files prefixed with _ are never discovered as plugins.
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import yaml from 'js-yaml';
const DIR = dirname(fileURLToPath(import.meta.url));
const STATES_PATH = join(DIR, '..', '..', 'templates', 'states.yml');
const MAX = 1900; // safety margin under Notion's 2000-char rich_text limit
// ── canonical states (templates/states.yml is the source of truth) ──────────
let _states;
function loadStates() {
if (_states) return _states;
const doc = yaml.load(readFileSync(STATES_PATH, 'utf-8'));
const labels = [], aliasMap = {};
for (const s of doc.states) {
labels.push(s.label);
aliasMap[s.label.toLowerCase()] = s.label;
for (const a of (s.aliases || [])) aliasMap[String(a).toLowerCase()] = s.label;
}
_states = { labels, aliasMap };
return _states;
}
/** Canonical label for a status (case-insensitive, alias-aware), or null. */
export function canonicalStatus(raw) {
if (!raw) return null;
const key = String(raw).replace(/\*\*/g, '').trim().toLowerCase();
return loadStates().aliasMap[key] || null;
}
export function statusLabels() { return loadStates().labels.slice(); }
// ── text → rich_text for property values (chunk-safe under the 2000-char limit) ──
export function rich(text) {
const str = String(text ?? '');
const out = [];
for (let i = 0; i < str.length || out.length === 0; i += MAX) out.push({ type: 'text', text: { content: str.slice(i, i + MAX) } });
return out;
}
export function plain(prop) {
return (prop?.title || prop?.rich_text || []).map((t) => t.plain_text).join('');
}
/**
* Build a Notion client bound to one user's token + parent page. Network goes
* through the injected `fetchFn` (the plugin passes ctx.fetch so the engine's
* allowedHosts/HTTPS/redirect guard applies); falls back to global fetch for
* standalone use. Nothing here reads process.env.
* @param {{ token: string, parent: string, fetch?: Function }} cfg
*/
export function createNotionClient({ token, parent, fetch: fetchFn = globalThis.fetch }) {
if (!token) throw new Error('NOTION_ACCESS_TOKEN is not set (.env) — the Notion plugin needs it to read/write.');
const HEADERS = { Authorization: `Bearer ${token}`, 'Notion-Version': '2025-09-03', 'Content-Type': 'application/json' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(path, method, body) {
await sleep(360); // ~3 req/s
// ctx.fetch throws on non-2xx (its message carries the body); the !r.ok
// branch below is the fallback when a plain global fetch is injected.
const r = await fetchFn(`https://api.notion.com/v1/${path}`, { method, headers: HEADERS, body: body ? JSON.stringify(body) : undefined });
const j = await r.json();
if (!r.ok) throw new Error(`Notion ${method} ${path} -> ${j.code}: ${j.message}`);
return j;
}
/** Create a page in a data source. `markdown` (optional) becomes the page body. */
async function createPage(dataSourceId, properties, markdown) {
const body = { parent: { type: 'data_source_id', data_source_id: dataSourceId }, properties };
if (markdown) body.markdown = markdown;
return api('pages', 'POST', body);
}
/** Map of DB name → primary data source id for every DB under the parent page. */
async function resolveDBs() {
if (!parent) throw new Error('Set NOTION_PARENT_PAGE_ID in .env (the "Career Ops" parent page id).');
const out = {};
let cursor;
do {
const j = await api(`blocks/${parent}/children?page_size=100${cursor ? `&start_cursor=${cursor}` : ''}`, 'GET');
for (const b of j.results) {
if (b.type !== 'child_database') continue;
const db = await api(`databases/${b.id}`, 'GET');
out[b.child_database.title] = db.data_sources?.[0]?.id;
}
cursor = j.has_more ? j.next_cursor : null;
} while (cursor);
return out;
}
async function queryDB(dataSourceId) {
let cursor, all = [];
do {
const j = await api(`data_sources/${dataSourceId}/query`, 'POST', { page_size: 100, start_cursor: cursor });
all.push(...j.results);
cursor = j.has_more ? j.next_cursor : null;
} while (cursor);
return all;
}
function summarize(r) {
return {
id: r.id,
company: plain(r.properties.Company),
role: plain(r.properties.Role),
status: r.properties.Status?.select?.name || '',
score: r.properties.Score?.number ?? null,
jobUrl: r.properties.URL?.url || '',
url: r.url,
};
}
/** Match against "<company> / <role>" (substring, case-insensitive) or exact company. */
async function findRecords(dataSourceId, match) {
const m = String(match).toLowerCase().trim();
return (await queryDB(dataSourceId)).map(summarize).filter((r) => {
const hay = `${r.company} / ${r.role}`.toLowerCase();
return hay.includes(m) || r.company.toLowerCase() === m;
});
}
return { api, createPage, resolveDBs, queryDB, findRecords };
}
+113
View File
@@ -0,0 +1,113 @@
// @ts-check
// ── Reference seed ── This bundled plugin is a stable, reviewed example. To
// extend it, publish career-ops-plugin-<id> with "supersedesBundled": true and
// your version takes precedence once installed (see docs/PLUGINS.md). Bundled
// seeds take only security/compat fixes — feature work happens in the successor repo.
//
// Notion plugin — mirror your tracker to a Notion database (export) and read
// records back as job leads (search).
//
// Built on the Notion backend contributed by @pcomans in #959 (with thanks),
// reshaped per the plugin contract. The decisive change: Notion is an OPT-IN
// MIRROR, not a replacement backend. data/applications.md stays the canonical
// source of truth (the web reads it); `export` pushes a read-only snapshot of it
// to the user's own Notion DB. The core never writes to Notion as primary, and
// modes are not edited — this lives entirely behind `node plugins.mjs run notion`.
//
// Setup: a "Career Ops" parent page in Notion containing an "Applications" DB
// with Company / Role / Status / Score / URL properties, shared with your
// internal integration. Enable in config/plugins.yml; keys in .env.
//
// node plugins.mjs run notion export # mirror tracker → Notion
// node plugins.mjs run notion search "platform" # read matching records → pipeline
import { createNotionClient, rich, canonicalStatus } from './_notion.mjs';
function clientFromCtx(ctx) {
return createNotionClient({
token: ctx?.env?.NOTION_ACCESS_TOKEN,
parent: ctx?.env?.NOTION_PARENT_PAGE_ID,
fetch: ctx?.fetch, // route through the engine's allowedHosts/redirect guard
});
}
async function applicationsDb(client) {
const dbs = await client.resolveDBs();
const apps = dbs['Applications'];
if (!apps) throw new Error('No "Applications" database found under the Career Ops page — create it and share the integration with it.');
return apps;
}
/**
* Parse a tracker score cell into a numeric value for the Notion DB Score property.
*
* Scores in applications.md may be formatted like `4.2/5`, `**4.2/5**`, `4.25`, etc.
* Strips formatting and extracts the first numeric value so slash-formatted
* scores (e.g. 4.2/5) are not mangled into 4.25 (#1414).
*
* @param {unknown} s - Raw score value from tracker row.
* @returns {number} Parsed score, or NaN if no valid number is present.
*/
export function parseScore(s) {
const m = String(s ?? '').replace(/\*\*/g, '').match(/([\d.]+)/);
return m ? parseFloat(m[1]) : NaN;
}
export default {
parseScore,
/**
* export: upsert each tracker row into the user's Notion Applications DB.
* Receives a frozen read-only snapshot of the tracker — never a file handle.
* @param {{ applications: Array<Record<string,string>> }} snapshot
* @param {any} ctx
*/
async export(snapshot, ctx) {
const rows = Array.isArray(snapshot?.applications) ? snapshot.applications : [];
if (rows.length === 0) return { pushed: 0 };
const client = clientFromCtx(ctx);
const apps = await applicationsDb(client);
let pushed = 0;
for (const row of rows) {
const company = (row.company || '').trim();
const role = (row.role || '').trim();
if (!company || !role) continue;
const props = { Role: { title: rich(role) }, Company: { rich_text: rich(company) } };
const status = canonicalStatus(row.status);
if (status) props.Status = { select: { name: status } };
const score = parseScore(row.score);
if (Number.isFinite(score)) props.Score = { number: score };
if (ctx?.dryRun) { ctx.log(`would push: ${company} — ${role}`); pushed++; continue; }
// Upsert: update an existing company+role record, else create one.
const dupes = (await client.findRecords(apps, `${company} / ${role}`))
.filter((h) => h.company.toLowerCase() === company.toLowerCase() && h.role.toLowerCase() === role.toLowerCase());
if (dupes.length) await client.api(`pages/${dupes[0].id}`, 'PATCH', { properties: props });
else await client.createPage(apps, props);
pushed++;
}
return { pushed };
},
/**
* search: return Notion records matching a query as Job[]. Only records that
* carry a job posting in a `URL` property are returned (e.g. a postings DB, or
* leads you added in Notion). Note: `export` mirrors the tracker (company/role/
* status/score) and does NOT set a job URL, so export-created rows are not
* round-tripped by search — that's intentional, they already live in your
* tracker. The engine writes any results to the pipeline canonically.
* @param {string} query
* @param {any} ctx
*/
async search(query, ctx) {
const client = clientFromCtx(ctx);
const apps = await applicationsDb(client);
const hits = await client.findRecords(apps, query);
return hits
.filter((h) => h.jobUrl && /^https?:\/\//i.test(h.jobUrl))
.map((h) => ({ title: h.role || 'Notion record', url: h.jobUrl, company: h.company || '', location: '' }));
},
};
+13
View File
@@ -0,0 +1,13 @@
{
"id": "notion",
"name": "Notion",
"version": "1.0.0",
"apiVersion": 1,
"description": "Mirror your tracker to a Notion database and read records back as job leads.",
"hooks": ["export", "search"],
"requiredEnv": ["NOTION_ACCESS_TOKEN", "NOTION_PARENT_PAGE_ID"],
"allowedHosts": ["api.notion.com"],
"skill": "skill.md",
"humanInTheLoop": true,
"homepage": "https://developers.notion.com"
}
+31
View File
@@ -0,0 +1,31 @@
---
name: career-ops-plugin-notion
description: How to mirror the career-ops tracker to a Notion database and read records back as job leads.
license: MIT
---
# notion plugin
Mirrors your application tracker to a Notion database (export) and reads records
back into the pipeline (search). `data/applications.md` stays the source of
truth — Notion is an additive mirror.
## Commands
- `node plugins.mjs run notion export` — push each tracker row (company / role /
status / score) to the "Applications" database under your Career Ops page.
Add `--dry-run` to preview without writing.
- `node plugins.mjs run notion search "<query>"` — return Notion records that
carry a job URL, matching the query, and append them to the pipeline.
## Setup
A "Career Ops" parent page in Notion containing an "Applications" database with
Company / Role / Status / Score / URL properties, shared with your internal
integration. Put `NOTION_ACCESS_TOKEN` + `NOTION_PARENT_PAGE_ID` in `.env`.
## Data it produces
`search` returns `Job[]` ({ title, url, company, location }) for records that
have a job URL; the engine writes them to the pipeline. `export` returns
{ pushed: N } — it never writes local files.